From a6d5d3fbcd9c06a4a91cf5f9c07a287fe4ebe9f9 Mon Sep 17 00:00:00 2001 From: IkedaNoritaka <50833174+NoritakaIkeda@users.noreply.github.com> Date: Mon, 6 Oct 2025 21:42:24 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8(github):=20Add=20username-based?= =?UTF-8?q?=20installation=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add getInstallationsForUsername function to filter installations by GitHub username - Add getInstallationsForUsername helper function - Check organization membership via GitHub API for org installations - Add createAppOctokit helper for app-level authentication - Update projects/new page to use username-based installation filtering - Derive GitHub username from Supabase user metadata and identities This enables filtering GitHub App installations to show only those relevant to the authenticated user (personal account or organizations they belong to). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- frontend/apps/app/app/projects/new/page.tsx | 40 +++++++- .../github/src/api.server.ts | 98 ++++++++++++++++++- 2 files changed, 135 insertions(+), 3 deletions(-) diff --git a/frontend/apps/app/app/projects/new/page.tsx b/frontend/apps/app/app/projects/new/page.tsx index 69236ba8a3..cd591c5768 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,43 @@ 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` + const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null + + const usernameFromUserMetadata = (() => { + const userMetadata = user.user_metadata as unknown + if (isRecord(userMetadata)) { + const userNameField = userMetadata['user_name'] + if (typeof userNameField === 'string') return userNameField + } + return undefined + })() + + 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 = usernameFromUserMetadata ?? 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,102 @@ 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 getInstallationsForOwners = async ( + ownerLogins: string[], +): Promise<{ installations: Installation[] }> => { + const appOctokit = await createAppOctokit() + + const allInstallations = (await appOctokit.paginate( + appOctokit.request, + 'GET /app/installations', + )) as Installation[] + + if (!ownerLogins || ownerLogins.length === 0) { + return { installations: allInstallations as Installation[] } + } + + const allowedOwnerLogins = new Set( + ownerLogins.map((login) => login.toLowerCase()), + ) + + const filteredInstallations = allInstallations.filter( + (installation: Installation) => { + const accountLogin = ( + installation.account as { login?: string } | null + )?.login + return accountLogin + ? allowedOwnerLogins.has(accountLogin.toLowerCase()) + : false + }, + ) as Installation[] + + return { installations: filteredInstallations } +} + +export const getInstallationsForUsername = async ( + username: string, +): Promise<{ installations: Installation[] }> => { + const appOctokit = await createAppOctokit() + + const allInstallations = (await appOctokit.paginate( + appOctokit.request, + 'GET /app/installations', + )) 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) + } + continue + } + + if (accountType === 'Organization') { + try { + // Authenticate as the installation to check membership for the user directly + const installationOctokit = await createOctokit(installation.id) + await installationOctokit.request( + 'GET /orgs/{org}/members/{username}', + { + org: accountLogin, + username, + }, + ) + // If the request succeeds, the user is a member + matchedInstallations.push(installation) + } catch { + // 404 or permission issues -> treat as not a member + } + } + } + + return { installations: matchedInstallations } +} + export const getPullRequestDetails = async ( installationId: number, owner: string, From 26e0e20872c91af65350edeb07ed0b62c0226918 Mon Sep 17 00:00:00 2001 From: IkedaNoritaka <50833174+NoritakaIkeda@users.noreply.github.com> Date: Tue, 7 Oct 2025 17:08:29 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F(github):=20Simplify=20ge?= =?UTF-8?q?tInstallationsForUsername=20to=20return=20all=20installations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove username-based filtering logic temporarily for debugging - Comment out organization membership check to simplify implementation - Return all installations regardless of username match - Remove unused getInstallationsForOwners function - Simplify username derivation in page.tsx to only use identities This commit temporarily removes filtering to debug installation visibility issues. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- frontend/apps/app/app/projects/new/page.tsx | 15 +-- .../github/src/api.server.ts | 113 +++++++----------- 2 files changed, 45 insertions(+), 83 deletions(-) diff --git a/frontend/apps/app/app/projects/new/page.tsx b/frontend/apps/app/app/projects/new/page.tsx index cd591c5768..e5462d0823 100644 --- a/frontend/apps/app/app/projects/new/page.tsx +++ b/frontend/apps/app/app/projects/new/page.tsx @@ -31,19 +31,12 @@ export default async function NewProjectPage() { redirect(urlgen('login')) } - // Derive GitHub username from Supabase user metadata (GitHub provider) without using `any` + // 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 usernameFromUserMetadata = (() => { - const userMetadata = user.user_metadata as unknown - if (isRecord(userMetadata)) { - const userNameField = userMetadata['user_name'] - if (typeof userNameField === 'string') return userNameField - } - return undefined - })() - const usernameFromIdentities = (() => { const identities = Array.isArray(user.identities) ? user.identities : [] const githubIdentity = identities.find( @@ -60,7 +53,7 @@ export default async function NewProjectPage() { return undefined })() - const githubLogin = usernameFromUserMetadata ?? usernameFromIdentities + const githubLogin = usernameFromIdentities if (!githubLogin) { console.error('GitHub login not found on user metadata') diff --git a/frontend/internal-packages/github/src/api.server.ts b/frontend/internal-packages/github/src/api.server.ts index 50becba748..3c19b809a3 100644 --- a/frontend/internal-packages/github/src/api.server.ts +++ b/frontend/internal-packages/github/src/api.server.ts @@ -33,88 +33,57 @@ const createAppOctokit = async () => { return octokit } -export const getInstallationsForOwners = async ( - ownerLogins: string[], -): Promise<{ installations: Installation[] }> => { - const appOctokit = await createAppOctokit() - - const allInstallations = (await appOctokit.paginate( - appOctokit.request, - 'GET /app/installations', - )) as Installation[] - - if (!ownerLogins || ownerLogins.length === 0) { - return { installations: allInstallations as Installation[] } - } - - const allowedOwnerLogins = new Set( - ownerLogins.map((login) => login.toLowerCase()), - ) - - const filteredInstallations = allInstallations.filter( - (installation: Installation) => { - const accountLogin = ( - installation.account as { login?: string } | null - )?.login - return accountLogin - ? allowedOwnerLogins.has(accountLogin.toLowerCase()) - : false - }, - ) as Installation[] - - return { installations: filteredInstallations } -} - export const getInstallationsForUsername = async ( username: string, ): Promise<{ installations: Installation[] }> => { const appOctokit = await createAppOctokit() + const _normalizedUsername = username.toLowerCase() const allInstallations = (await appOctokit.paginate( appOctokit.request, 'GET /app/installations', )) 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) - } - continue - } - - if (accountType === 'Organization') { - try { - // Authenticate as the installation to check membership for the user directly - const installationOctokit = await createOctokit(installation.id) - await installationOctokit.request( - 'GET /orgs/{org}/members/{username}', - { - org: accountLogin, - username, - }, - ) - // If the request succeeds, the user is a member - matchedInstallations.push(installation) - } catch { - // 404 or permission issues -> treat as not a member - } - } - } - - return { installations: matchedInstallations } + // 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) + // } + // 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, + // }), + // ) + + // // 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 ( From 57a6050ba21dca33b6f4430e851788dd1466ad15 Mon Sep 17 00:00:00 2001 From: IkedaNoritaka <50833174+NoritakaIkeda@users.noreply.github.com> Date: Tue, 7 Oct 2025 17:44:25 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B(github):=20Change=20endpoint?= =?UTF-8?q?=20to=20/installation/repositories=20and=20add=20debug=20loggin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change API endpoint from /app/installations to /installation/repositories - Restore filtering logic with username matching - Add console.info debug logging for matched accounts and usernames - Keep returning allInstallations for debugging (not filtered yet) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../github/src/api.server.ts | 81 ++++++++++--------- 1 file changed, 41 insertions(+), 40 deletions(-) diff --git a/frontend/internal-packages/github/src/api.server.ts b/frontend/internal-packages/github/src/api.server.ts index 3c19b809a3..406aac2cdb 100644 --- a/frontend/internal-packages/github/src/api.server.ts +++ b/frontend/internal-packages/github/src/api.server.ts @@ -37,51 +37,52 @@ export const getInstallationsForUsername = async ( username: string, ): Promise<{ installations: Installation[] }> => { const appOctokit = await createAppOctokit() - const _normalizedUsername = username.toLowerCase() const allInstallations = (await appOctokit.paginate( appOctokit.request, - 'GET /app/installations', + '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) - // } - // 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, - // }), - // ) - - // // 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 - // } - // } + 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 } }