- Merge duplicate react imports in repository-selector - Use <dialog open> instead of role=dialog in settings popover - Export type re-export from source module in use-github-repo-search - Sort action args alphabetically in searchGithubRepositories - Disable react-compiler rule for synchronous setState in debounced search - Simplify getActiveGithubConnection query
549 lines
17 KiB
TypeScript
549 lines
17 KiB
TypeScript
"use node";
|
|
|
|
import { env } from "@code/env/convex";
|
|
import { decodeGitConnectionInput } from "@code/primitives/execution-runtime";
|
|
import {
|
|
GITHUB_SERVER_URL,
|
|
PUTER_GIT_SERVER_URL,
|
|
} from "@code/primitives/git-provider";
|
|
import { makeFunctionReference } from "convex/server";
|
|
import { ConvexError, v } from "convex/values";
|
|
import { Effect } from "effect";
|
|
|
|
import { internal } from "./_generated/api";
|
|
import type { Doc, Id } from "./_generated/dataModel";
|
|
import { action } from "./_generated/server";
|
|
import type { ActionCtx } from "./_generated/server";
|
|
import { authComponent, createAuth } from "./auth";
|
|
|
|
const MIN_SEARCH_QUERY_LENGTH = 2;
|
|
const DEFAULT_SEARCH_PER_PAGE = 20;
|
|
const MAX_SEARCH_PER_PAGE = 50;
|
|
|
|
const encryptionKey = async (): Promise<CryptoKey> => {
|
|
if (!env.GIT_CREDENTIAL_ENCRYPTION_KEY) {
|
|
throw new ConvexError("Git credential encryption is not configured");
|
|
}
|
|
const bytes = Buffer.from(env.GIT_CREDENTIAL_ENCRYPTION_KEY, "base64url");
|
|
if (bytes.byteLength !== 32) {
|
|
throw new ConvexError("Git credential encryption key must be 32 bytes");
|
|
}
|
|
return await crypto.subtle.importKey("raw", bytes, "AES-GCM", false, [
|
|
"encrypt",
|
|
"decrypt",
|
|
]);
|
|
};
|
|
|
|
const encryptCredential = async (credential: string) => {
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
const encrypted = await crypto.subtle.encrypt(
|
|
{ iv, name: "AES-GCM" },
|
|
await encryptionKey(),
|
|
new TextEncoder().encode(credential)
|
|
);
|
|
return {
|
|
credentialCiphertext: Buffer.from(encrypted).toString("base64url"),
|
|
credentialIv: Buffer.from(iv).toString("base64url"),
|
|
};
|
|
};
|
|
|
|
export const decryptCredential = async (
|
|
credentialCiphertext: string,
|
|
credentialIv: string
|
|
): Promise<string> => {
|
|
const decrypted = await crypto.subtle.decrypt(
|
|
{
|
|
iv: Buffer.from(credentialIv, "base64url"),
|
|
name: "AES-GCM",
|
|
},
|
|
await encryptionKey(),
|
|
Buffer.from(credentialCiphertext, "base64url")
|
|
);
|
|
return new TextDecoder().decode(decrypted);
|
|
};
|
|
|
|
interface ExternalUserInfo {
|
|
readonly externalAccountId: string;
|
|
readonly externalEmail?: string;
|
|
readonly externalUsername: string;
|
|
}
|
|
|
|
/** Fetch the Gitea user identity from /api/v1/user using a PAT. */
|
|
const fetchGiteaUser = async (
|
|
serverUrl: string,
|
|
token: string
|
|
): Promise<ExternalUserInfo> => {
|
|
const response = await fetch(
|
|
`${serverUrl.replace(/\/+$/u, "")}/api/v1/user`,
|
|
{
|
|
headers: { authorization: `token ${token}` },
|
|
}
|
|
);
|
|
if (!response.ok) {
|
|
throw new ConvexError(
|
|
`Gitea user verification failed (${response.status})`
|
|
);
|
|
}
|
|
const user = (await response.json()) as {
|
|
readonly email?: string | null;
|
|
readonly id: number;
|
|
readonly login: string;
|
|
};
|
|
return {
|
|
externalAccountId: String(user.id),
|
|
externalEmail: user.email ?? undefined,
|
|
externalUsername: user.login,
|
|
};
|
|
};
|
|
|
|
/** Fetch the GitHub user identity from /user using an OAuth token. */
|
|
const fetchGithubUser = async (token: string): Promise<ExternalUserInfo> => {
|
|
const response = await fetch("https://api.github.com/user", {
|
|
headers: {
|
|
accept: "application/vnd.github+json",
|
|
authorization: `Bearer ${token}`,
|
|
},
|
|
});
|
|
if (!response.ok) {
|
|
throw new ConvexError(
|
|
`GitHub user verification failed (${response.status})`
|
|
);
|
|
}
|
|
const user = (await response.json()) as {
|
|
readonly email?: string | null;
|
|
readonly id: number;
|
|
readonly login: string;
|
|
};
|
|
return {
|
|
externalAccountId: String(user.id),
|
|
externalEmail: user.email ?? undefined,
|
|
externalUsername: user.login,
|
|
};
|
|
};
|
|
|
|
const syncRepositoriesRef = makeFunctionReference<
|
|
"action",
|
|
{ connectionId: Id<"gitConnections"> },
|
|
{ synced: number }
|
|
>("gitConnections:syncRepositories");
|
|
|
|
export const connectGitea = action({
|
|
args: {
|
|
token: v.string(),
|
|
username: v.optional(v.string()),
|
|
},
|
|
handler: async (
|
|
ctx,
|
|
args
|
|
): Promise<{ connectionId: Id<"gitConnections"> }> => {
|
|
const userId = await ctx.auth.getUserIdentity().then((identity) => {
|
|
if (!identity) {
|
|
throw new ConvexError("Authentication required");
|
|
}
|
|
return identity.tokenIdentifier;
|
|
});
|
|
const connection = await Effect.runPromise(
|
|
decodeGitConnectionInput({
|
|
credential: args.token,
|
|
credentialKind: "token",
|
|
provider: "gitea",
|
|
serverUrl: PUTER_GIT_SERVER_URL,
|
|
username: args.username,
|
|
})
|
|
);
|
|
// Verify the token and fetch external identity before persisting.
|
|
const externalUser = await fetchGiteaUser(
|
|
connection.serverUrl,
|
|
connection.credential
|
|
);
|
|
const encrypted = await encryptCredential(connection.credential);
|
|
const connectionId = await ctx.runMutation(
|
|
internal.gitConnectionData.persist,
|
|
{
|
|
...encrypted,
|
|
...externalUser,
|
|
credentialKind: connection.credentialKind,
|
|
provider: connection.provider,
|
|
serverUrl: connection.serverUrl,
|
|
userId,
|
|
username: connection.username ?? externalUser.externalUsername,
|
|
}
|
|
);
|
|
// Sync accessible repositories after connecting.
|
|
await ctx.runAction(syncRepositoriesRef, {
|
|
connectionId,
|
|
});
|
|
return { connectionId };
|
|
},
|
|
});
|
|
|
|
export const connectGithub = action({
|
|
args: {},
|
|
handler: async (ctx): Promise<{ connectionId: Id<"gitConnections"> }> => {
|
|
const identity = await ctx.auth.getUserIdentity();
|
|
if (!identity) {
|
|
throw new ConvexError("Authentication required");
|
|
}
|
|
const { auth, headers } = await authComponent.getAuth(createAuth, ctx);
|
|
const token = await auth.api.getAccessToken({
|
|
body: { providerId: "github" },
|
|
headers,
|
|
});
|
|
if (!token.accessToken) {
|
|
throw new ConvexError("GitHub account is not connected");
|
|
}
|
|
const externalUser = await fetchGithubUser(token.accessToken);
|
|
const encrypted = await encryptCredential(token.accessToken);
|
|
const grantedScopesJson =
|
|
token.scopes && token.scopes.length > 0
|
|
? JSON.stringify(token.scopes)
|
|
: undefined;
|
|
const connectionId = await ctx.runMutation(
|
|
internal.gitConnectionData.persist,
|
|
{
|
|
...encrypted,
|
|
...externalUser,
|
|
credentialKind: "oauth",
|
|
grantedScopesJson,
|
|
provider: "github",
|
|
serverUrl: GITHUB_SERVER_URL,
|
|
userId: identity.tokenIdentifier,
|
|
}
|
|
);
|
|
// Sync accessible repositories after connecting.
|
|
await ctx.runAction(syncRepositoriesRef, {
|
|
connectionId,
|
|
});
|
|
return { connectionId };
|
|
},
|
|
});
|
|
|
|
const getConnectionRef = makeFunctionReference<
|
|
"query",
|
|
{ connectionId: Id<"gitConnections"> },
|
|
Doc<"gitConnections"> | null
|
|
>("gitConnectionHealth:getConnection");
|
|
const resolvePersonalOrgRef = makeFunctionReference<
|
|
"query",
|
|
{ userId: string },
|
|
Id<"organizations"> | null
|
|
>("gitConnectionHealth:resolvePersonalOrg");
|
|
const syncRepositoriesBatchRef = makeFunctionReference<
|
|
"mutation",
|
|
{
|
|
organizationId: Id<"organizations">;
|
|
providerAccountId: Id<"gitProviderAccounts">;
|
|
provider: "github" | "gitea";
|
|
repos: {
|
|
cloneUrl: string;
|
|
defaultBranch: string;
|
|
fullName: string;
|
|
name: string;
|
|
owner: string;
|
|
private: boolean;
|
|
providerRepositoryId: string;
|
|
serverUrl: string;
|
|
webUrl: string;
|
|
}[];
|
|
serverUrl: string;
|
|
},
|
|
number
|
|
>("gitProvisioning:syncRepositoriesBatch");
|
|
const getActiveGithubConnectionRef = makeFunctionReference<
|
|
"query",
|
|
{ organizationId: Id<"organizations"> },
|
|
Doc<"gitConnections"> | null
|
|
>("gitConnectionHealth:getActiveGithubConnection");
|
|
const getGithubRepoIdsByExternalIdsRef = makeFunctionReference<
|
|
"query",
|
|
{
|
|
organizationId: Id<"organizations">;
|
|
providerRepositoryIds: string[];
|
|
serverUrl: string;
|
|
},
|
|
{ id: string; providerRepositoryId: string }[]
|
|
>("gitConnectionHealth:getGithubRepoIdsByExternalIds");
|
|
|
|
export const syncRepositories = action({
|
|
args: { connectionId: v.id("gitConnections") },
|
|
handler: async (ctx, args): Promise<{ synced: number }> => {
|
|
// Actions have the authenticated identity, but `ctx.runQuery`/`runMutation`
|
|
// invoked from an action do NOT propagate it — so `requireCurrentOrganization`
|
|
// inside the sub-functions would see no identity. Resolve the identity here
|
|
// and pass the organizationId down explicitly.
|
|
const identity = await ctx.auth.getUserIdentity();
|
|
if (!identity) {
|
|
throw new ConvexError("Authentication required");
|
|
}
|
|
const organizationId = await ctx.runQuery(resolvePersonalOrgRef, {
|
|
userId: identity.tokenIdentifier,
|
|
});
|
|
if (!organizationId) {
|
|
throw new ConvexError("Organization not found");
|
|
}
|
|
const connection = await ctx.runQuery(getConnectionRef, {
|
|
connectionId: args.connectionId,
|
|
});
|
|
if (!connection || connection.organizationId !== organizationId) {
|
|
throw new ConvexError("Git connection not found");
|
|
}
|
|
const token = await decryptCredential(
|
|
connection.credentialCiphertext,
|
|
connection.credentialIv
|
|
);
|
|
|
|
let repos: {
|
|
clone_url: string;
|
|
default_branch: string;
|
|
full_name: string;
|
|
html_url: string;
|
|
id: number;
|
|
name: string;
|
|
owner: { login: string };
|
|
private: boolean;
|
|
}[];
|
|
|
|
if (connection.provider === "gitea") {
|
|
const response = await fetch(
|
|
`${connection.serverUrl.replace(/\/+$/u, "")}/api/v1/repos/search?limit=50`,
|
|
{ headers: { authorization: `token ${token}` } }
|
|
);
|
|
if (!response.ok) {
|
|
throw new ConvexError(
|
|
`Failed to list Gitea repositories (${response.status})`
|
|
);
|
|
}
|
|
const body = (await response.json()) as { data: typeof repos };
|
|
repos = body.data ?? [];
|
|
} else {
|
|
const response = await fetch(
|
|
"https://api.github.com/user/repos?sort=updated&per_page=50",
|
|
{
|
|
headers: {
|
|
accept: "application/vnd.github+json",
|
|
authorization: `Bearer ${token}`,
|
|
},
|
|
}
|
|
);
|
|
if (!response.ok) {
|
|
throw new ConvexError(
|
|
`Failed to list GitHub repositories (${response.status})`
|
|
);
|
|
}
|
|
repos = (await response.json()) as typeof repos;
|
|
}
|
|
|
|
const synced = await ctx.runMutation(syncRepositoriesBatchRef, {
|
|
organizationId,
|
|
provider: connection.provider,
|
|
providerAccountId:
|
|
connection.gitProviderAccountId ?? ("" as Id<"gitProviderAccounts">),
|
|
repos: repos.map((repo) => ({
|
|
cloneUrl: repo.clone_url,
|
|
defaultBranch: repo.default_branch ?? "main",
|
|
fullName: repo.full_name,
|
|
name: repo.name,
|
|
owner: repo.owner.login,
|
|
private: repo.private,
|
|
providerRepositoryId: String(repo.id),
|
|
serverUrl: connection.serverUrl,
|
|
webUrl: repo.html_url,
|
|
})),
|
|
serverUrl: connection.serverUrl,
|
|
});
|
|
return { synced };
|
|
},
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Live GitHub repository search
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface GithubSearchRepo {
|
|
readonly clone_url: string;
|
|
readonly default_branch: string | null;
|
|
readonly full_name: string;
|
|
readonly html_url: string;
|
|
readonly id: number;
|
|
readonly name: string;
|
|
readonly owner: { login: string };
|
|
readonly private: boolean;
|
|
}
|
|
|
|
const resolveActiveGithubConnection = async (
|
|
ctx: ActionCtx,
|
|
args: { connectionId?: Id<"gitConnections"> }
|
|
): Promise<{
|
|
connection: Doc<"gitConnections">;
|
|
organizationId: Id<"organizations">;
|
|
token: string;
|
|
}> => {
|
|
const identity = await ctx.auth.getUserIdentity();
|
|
if (!identity) {
|
|
throw new ConvexError("Authentication required");
|
|
}
|
|
const organizationId = await ctx.runQuery(resolvePersonalOrgRef, {
|
|
userId: identity.tokenIdentifier,
|
|
});
|
|
if (!organizationId) {
|
|
throw new ConvexError("Organization not found");
|
|
}
|
|
// If no explicit connection given, find the active GitHub connection.
|
|
const connection = args.connectionId
|
|
? await ctx.runQuery(getConnectionRef, { connectionId: args.connectionId })
|
|
: await ctx.runQuery(getActiveGithubConnectionRef, { organizationId });
|
|
if (!connection || connection.organizationId !== organizationId) {
|
|
throw new ConvexError("Git connection not found");
|
|
}
|
|
if (connection.provider !== "github") {
|
|
throw new ConvexError("This action requires a GitHub connection");
|
|
}
|
|
if (connection.state !== undefined && connection.state !== "active") {
|
|
throw new ConvexError(
|
|
`GitHub connection is ${connection.state}; reconnect before searching`
|
|
);
|
|
}
|
|
const token = await decryptCredential(
|
|
connection.credentialCiphertext,
|
|
connection.credentialIv
|
|
);
|
|
return { connection, organizationId, token };
|
|
};
|
|
|
|
export interface GithubRepositorySearchResult {
|
|
readonly cloneUrl: string;
|
|
readonly defaultBranch: string;
|
|
readonly fullName: string;
|
|
readonly gitRepositoryId: string;
|
|
readonly name: string;
|
|
readonly owner: string;
|
|
readonly privacy: "public" | "private";
|
|
readonly provider: "github";
|
|
readonly webUrl: string;
|
|
}
|
|
|
|
/**
|
|
* Live GitHub repository search using the authenticated user's stored
|
|
* encrypted credential. Searches across the user's accessible repos (owned,
|
|
* collaborator, and org member) via the GitHub search API, then upserts the
|
|
* results into gitRepositories so createProjectFromRepository works unchanged.
|
|
*/
|
|
export const searchGithubRepositories = action({
|
|
args: {
|
|
connectionId: v.optional(v.id("gitConnections")),
|
|
perPage: v.optional(v.number()),
|
|
query: v.string(),
|
|
},
|
|
handler: async (
|
|
ctx,
|
|
args
|
|
): Promise<readonly GithubRepositorySearchResult[]> => {
|
|
const query = args.query.trim();
|
|
if (query.length < MIN_SEARCH_QUERY_LENGTH) {
|
|
return [];
|
|
}
|
|
const { connection, organizationId, token } =
|
|
await resolveActiveGithubConnection(ctx, args);
|
|
|
|
// 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
|
|
);
|
|
const searchTerm = query.toLowerCase();
|
|
|
|
// 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})`
|
|
);
|
|
}
|
|
|
|
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 repos = matchingRepos.slice(0, perPage);
|
|
|
|
if (repos.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// Persist search results into gitRepositories so the existing
|
|
// createProjectFromRepository mutation works unchanged.
|
|
await ctx.runMutation(syncRepositoriesBatchRef, {
|
|
organizationId,
|
|
provider: "github",
|
|
providerAccountId:
|
|
connection.gitProviderAccountId ?? ("" as Id<"gitProviderAccounts">),
|
|
repos: repos.map((repo) => ({
|
|
cloneUrl: repo.clone_url,
|
|
defaultBranch: repo.default_branch ?? "main",
|
|
fullName: repo.full_name,
|
|
name: repo.name,
|
|
owner: repo.owner.login,
|
|
private: repo.private,
|
|
providerRepositoryId: String(repo.id),
|
|
serverUrl: GITHUB_SERVER_URL,
|
|
webUrl: repo.html_url,
|
|
})),
|
|
serverUrl: GITHUB_SERVER_URL,
|
|
});
|
|
|
|
// Read back the upserted repos so we return stable gitRepositoryIds.
|
|
const repoIds = await ctx.runQuery(getGithubRepoIdsByExternalIdsRef, {
|
|
organizationId,
|
|
providerRepositoryIds: repos.map((repo) => String(repo.id)),
|
|
serverUrl: GITHUB_SERVER_URL,
|
|
});
|
|
|
|
const idMap = new Map(repoIds.map((r) => [r.providerRepositoryId, r.id]));
|
|
return repos.map((repo) => ({
|
|
cloneUrl: repo.clone_url,
|
|
defaultBranch: repo.default_branch ?? "main",
|
|
fullName: repo.full_name,
|
|
gitRepositoryId: idMap.get(String(repo.id)) ?? "",
|
|
name: repo.name,
|
|
owner: repo.owner.login,
|
|
privacy: repo.private ? ("private" as const) : ("public" as const),
|
|
provider: "github" as const,
|
|
webUrl: repo.html_url,
|
|
}));
|
|
},
|
|
});
|