diff --git a/frontend/apps/app/app/projects/new/page.tsx b/frontend/apps/app/app/projects/new/page.tsx index 69236ba8a3..e5462d0823 100644 --- a/frontend/apps/app/app/projects/new/page.tsx +++ b/frontend/apps/app/app/projects/new/page.tsx @@ -1,4 +1,4 @@ -import { getInstallations } from '@liam-hq/github' +import { getInstallationsForUsername } from '@liam-hq/github' import { redirect } from 'next/navigation' import { ProjectNewPage } from '../../../components/ProjectNewPage' import { getOrganizationId } from '../../../features/organizations/services/getOrganizationId' @@ -31,7 +31,36 @@ export default async function NewProjectPage() { redirect(urlgen('login')) } - const { installations } = await getInstallations(data.session) + // Derive GitHub username from Supabase user metadata (GitHub provider) without using `any`. + // Supabase types `user.user_metadata` and `identity_data` as `any`, so we first + // treat them as `unknown` and then narrow with a custom type guard. + const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null + + const usernameFromIdentities = (() => { + const identities = Array.isArray(user.identities) ? user.identities : [] + const githubIdentity = identities.find( + (identity) => + identity && + typeof identity.provider === 'string' && + identity.provider === 'github', + ) + const identityData = githubIdentity?.identity_data as unknown + if (isRecord(identityData)) { + const userNameField = identityData['user_name'] + if (typeof userNameField === 'string') return userNameField + } + return undefined + })() + + const githubLogin = usernameFromIdentities + + if (!githubLogin) { + console.error('GitHub login not found on user metadata') + redirect(urlgen('login')) + } + + const { installations } = await getInstallationsForUsername(githubLogin) return ( { const octokit = new Octokit({ @@ -21,6 +21,72 @@ const createOctokit = async (installationId: number) => { return octokit } +const createAppOctokit = async () => { + const octokit = new Octokit({ + authStrategy: createAppAuth, + auth: { + appId: process.env['GITHUB_APP_ID'], + privateKey: process.env['GITHUB_PRIVATE_KEY']?.replace(/\\n/g, '\n'), + }, + }) + + return octokit +} + +export const getInstallationsForUsername = async ( + username: string, +): Promise<{ installations: Installation[] }> => { + const appOctokit = await createAppOctokit() + + const allInstallations = (await appOctokit.paginate( + appOctokit.request, + 'GET /installation/repositories', + )) as Installation[] + + const normalizedUsername = username.toLowerCase() + + const matchedInstallations: Installation[] = [] + + for (const installation of allInstallations) { + const account = installation.account as { + type?: string + login?: string + } | null + const accountLogin = account?.login + const accountType = account?.type + + if (!accountLogin || !accountType) continue + + if (accountType === 'User') { + if (accountLogin.toLowerCase() === normalizedUsername) { + matchedInstallations.push(installation) + console.info(accountLogin.toLowerCase()) + } + continue + } + + if (accountType === 'Organization') { + // Authenticate as the installation to check membership for the user directly + const installationOctokit = await createOctokit(installation.id) + const membershipResult = await fromPromise( + installationOctokit.request('GET /orgs/{org}/members/{username}', { + org: accountLogin, + username, + }), + ) + console.info(username) + + // If the request succeeds, the user is a member + if (membershipResult.isOk()) { + matchedInstallations.push(installation) + } + // Errors (e.g., 404, permission) are treated as non-membership + } + } + + return { installations: allInstallations } +} + export const getPullRequestDetails = async ( installationId: number, owner: string,