import { Octokit, type App } from "octokit" import * as Sentry from "@sentry/node" import { createAppInstallation, createOrUpdateUser, deleteOrganizationMemberApiKeys, getAppInstallationByInstalltionId, getUserById, organizationMemberRepository, organizationRepository, prisma, upsertRepository, } from "@codeflash-ai/common" import { sendSlackMessage } from "./slack_util.js" import { isCodeflashEmployee } from "../utils/employee-utils.js" // Channel for GitHub App installation alerts (falls back to default channel) const GITHUB_ALERTS_CHANNEL = process.env.SLACK_CHANNEL_ID // Dependencies interface for easier testing export interface GithubUtilsDependencies { console: { error: typeof console.error } } // Default dependencies let dependencies: GithubUtilsDependencies = { console: { error: console.error, }, } // For testing - allow dependency injection export function setGithubUtilsDependencies(deps: Partial) { dependencies = { ...dependencies, ...deps } } export function resetGithubUtilsDependencies() { dependencies = { console: { error: console.error, }, } } // Checks if the user is a collaborator on the repository export async function isUserCollaborator( installationOctokit: Octokit, owner: string, repo: string, nickname: string, ): Promise { try { const response = await installationOctokit.rest.repos.checkCollaborator({ owner, repo, username: nickname, }) // If the request is successful, the user is a collaborator and we can continue return response.status === 204 } catch (error: any) { if (error.status === 404) { return false // User is not a collaborator } // Handle other potential errors throw error // Rethrow error for upstream handling } } export async function getInstallationOctokitByOwner( githubApp: App, owner: string, repo: string, userId?: string, ): Promise { try { const repoInstallation = await githubApp.octokit.rest.apps.getRepoInstallation({ owner, repo, }) const installationId = repoInstallation.data.id return await githubApp.getInstallationOctokit(installationId) } catch (error: any) { dependencies.console.error(`Error getting installation Octokit for ${owner}/${repo}:`, error) if (error.status === 404) { // Send Slack alert for GitHub App installation failures // This helps the team proactively reach out to affected customers // Skip notification for Codeflash employees to reduce internal noise if (!userId || !isCodeflashEmployee(userId)) { sendSlackMessage( { text: `GitHub App Installation Issue`, blocks: [ { type: "header", text: { type: "plain_text", text: "GitHub App Installation Issue", emoji: true, }, }, { type: "section", fields: [ { type: "mrkdwn", text: `*Repository:*\n${owner}/${repo}`, }, { type: "mrkdwn", text: `*Error:*\nApp not installed (404)`, }, ], }, { type: "section", text: { type: "mrkdwn", text: `This may indicate the customer needs to accept updated GitHub App permissions or reinstall the app.\n\n`, }, }, { type: "context", elements: [ { type: "mrkdwn", text: `Timestamp: ${new Date().toISOString()}`, }, ], }, ], }, GITHUB_ALERTS_CHANNEL, ).catch(slackError => { // Don't let Slack errors affect the main flow console.error("Failed to send Slack alert for GitHub App installation issue:", slackError) }) } return Error(`GitHub App is not installed on the repository ${owner}/${repo}`) } else { return Error(`Error checking GitHub App installation status for repo ${owner}/${repo}`) } } } function isLabelAlreadyExistsError(error: any): boolean { return ( error?.status === 422 && Array.isArray(error?.response?.data?.errors) && error.response.data.errors.some( (e: any) => e.resource === "Label" && e.code === "already_exists" && e.field === "name", ) ) } // Ensures that a label exists on a repository, creating it if it doesn't export async function ensureLabelExists( installationOctokit: Octokit, owner: string, repo: string, labelName: string, color: string, description: string, ): Promise { try { await installationOctokit.rest.issues.getLabel({ owner, repo, name: labelName, }) } catch (error: any) { if (error.status === 404) { // Label does not exist, create it try { await installationOctokit.rest.issues.createLabel({ owner, repo, name: labelName, color, description, }) } catch (createError: any) { // Ignore "already_exists" error - another concurrent request may have created it if (!isLabelAlreadyExistsError(createError)) { Sentry.captureException(createError, { extra: { owner, repo, labelName, context: "ensureLabelExists - createLabel failed" }, }) } } } else { // Log to Sentry and prevent throwing the error Sentry.captureException(error, { extra: { owner, repo, labelName, context: "ensureLabelExists - getLabel failed" }, }) } } } // Adds a label to a pull request, creating the label if it doesn't exist export async function addLabelToPullRequest( installationOctokit: Octokit, owner: string, repo: string, pullNumber: number, labelName: string = "⚡️ codeflash", labelColor: string = "FFC043", description: string = "Optimization PR opened by Codeflash AI", ): Promise { await ensureLabelExists(installationOctokit, owner, repo, labelName, labelColor, description) await installationOctokit.rest.issues.addLabels({ owner, repo, issue_number: pullNumber, labels: [labelName], }) } /** * Fetches the user's role in an organization. * Expected values: "admin" (owner), "member" (normal member). */ async function fetchOrgRole(octokit: Octokit, owner: string, username: string): Promise { try { const { data: membership } = await octokit.rest.orgs.getMembershipForUser({ org: owner, username, }) return membership.role || "" } catch (error) { console.error(`Error fetching org role for ${username} in ${owner}:`, error) Sentry.captureException(error) return "" } } /** * Fetches the user's permission level in a repository. * Expected values: "admin", "maintain", "write", "triage", "read". */ async function fetchRepoRole( octokit: Octokit, owner: string, repo: string, username: string, ): Promise { try { const { data: permissions } = await octokit.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username, }) console.log(`Repo role for ${username} in ${owner}/${repo}: ${permissions.permission}`) return permissions.permission || "" } catch (error) { console.error(`Error fetching repo role for ${username} in ${owner}/${repo}:`, error) Sentry.captureException(error) return "" } } /** * Returns the user's role in an org or a repo, depending on what's available. * If it fails, logs the error to Sentry and returns an empty string. */ export async function getUserRole({ octokit, owner, repo, username, isOrg, }: { octokit: Octokit owner: string repo: string username: string isOrg: boolean }): Promise { if (isOrg) { // Fetch org membership role // Expected values: "admin" (org owner) or "member" (regular member) return await fetchOrgRole(octokit, owner, username) } else { // Fetch repo permission // Expected values: "admin", "maintain", "write", "triage", "read" return await fetchRepoRole(octokit, owner, repo, username) } } async function getInstallations(app: App) { let installations: any[] = [] let page = 1 console.log("fetching installations...") while (true) { try { const response = await app.octokit.rest.apps.listInstallations({ per_page: 90, // max is 90 page, }) if (!response.data.length) break installations.push(...response.data) page++ } catch (error) { console.error("Error fetching installations:", error) Sentry.captureException(error) break } } console.log("Done") return installations } /** * Checks whether a branch exists in a GitHub repository. * * @param {object} params - Parameters for branch existence check. * @param {string} params.owner - The repository owner. * @param {string} params.repo - The repository name. * @param {string} params.branch - The branch name to check. * @param {Octokit} params.installationOctokit - The authenticated installation Octokit client for the repo. * @returns {Promise} - Returns true if the branch exists, false if not. * * @example * const exists = await isBranchExists({ * owner: "octocat", * repo: "Hello-World", * branch: "feature-branch", * installationOctokit, * }); * console.log("Branch exists:", exists); */ export async function isBranchExists({ owner, repo, branch, installationOctokit, }: { owner: string repo: string branch: string installationOctokit: Octokit }): Promise { try { await installationOctokit.rest.git.getRef({ owner, repo, ref: `heads/${branch}`, }) return true } catch (error: any) { if (error.status === 404) { return false } Sentry.captureException(error) dependencies.console.error( `Error checking if branch ${branch} exists in ${owner}/${repo}:`, error, ) return false } } async function filterInstalltionAccountByOrg(installations: any, orgNames?: string[]) { let orgInstallations: typeof installations = [] for (const installation of installations) { const login = installation.account?.login if (installation.account?.type === "Organization" && login) { orgInstallations.push(installation) } } if (orgNames && orgNames.length) { orgInstallations = orgInstallations.filter(install => orgNames.includes(install.account!.login)) } return orgInstallations } async function getReposForInstallation(installationOctokit: Octokit): Promise { const repos: any[] = [] let page = 1 while (true) { try { const reposResponse = await installationOctokit.rest.apps.listReposAccessibleToInstallation({ per_page: 90, page, }) if (!reposResponse.data.repositories.length) break repos.push(...reposResponse.data.repositories) page++ } catch (error) { console.error("Error fetching repos for installation:", error) Sentry.captureException(error) break } } return repos } async function getMembersWithRolesForOrg( installationOctokit: Octokit, orgLogin: string, ): Promise<{ id: number; username: string; role: string }[]> { const members: { id: number; username: string; role: string }[] = [] const memberData: { id: number; login: string }[] = [] let page = 1 // ---- Fetch members (paginated) ---- while (true) { try { const membersResponse = await installationOctokit.rest.orgs.listMembers({ org: orgLogin, per_page: 90, page, }) if (!membersResponse.data.length) break memberData.push( ...membersResponse.data.map(member => ({ id: member.id, login: member.login, })), ) page++ } catch (error) { console.error(`Error fetching members for org ${orgLogin}:`, error) Sentry.captureException(error) break } } // ---- Fetch roles for each member ---- for (const member of memberData) { try { const role = await fetchOrgRole(installationOctokit, orgLogin, member.login) members.push({ id: member.id, username: member.login, role }) } catch (error) { console.error(`Error fetching role for member ${member.login} in org ${orgLogin}:`, error) Sentry.captureException(error) } } return members } export async function syncOrgsWithMembers(app: App, orgNames?: string[]) { Sentry.captureMessage("syncOrgsWithMembers started") const installations = await getInstallations(app) const orgInstallations = await filterInstalltionAccountByOrg(installations, orgNames) for (const installation of orgInstallations) { try { const login = installation.account!.login let repos: any[] = [] let members: { id: number; username: string; role: string }[] = [] console.log("fetch repos for " + login) const installationOctokit = await app.getInstallationOctokit(installation.id) const accountLogin = installation.account!.login const accountType = installation.account!.type // Check if the installation exists, if not, create it const installationExists = await getAppInstallationByInstalltionId(installation.id) if (!installationExists) { await createAppInstallation({ installation_id: installation.id, account_id: installation.account!.id, account_login: accountLogin, account_type: accountType, }) console.log(`Installation created for ID: ${installation.id}`) } // --- Fetch all repos with pagination --- repos = await getReposForInstallation(installationOctokit) console.log("Done... ") console.log("fetch members for " + login) // --- Fetch all members with roles --- members = await getMembersWithRolesForOrg(installationOctokit, login) console.log("Done... ") // Get or create organization let organization = await organizationRepository.findByGithubOrgId( String(installation.account!.id), ) if (!organization) { organization = await organizationRepository.create({ github_org_id: String(installation.account!.id), name: login, added_by: "Codeflash", }) } // Fetch existing members in organization from DB const existingMembersInDb = await prisma.organization_members.findMany({ where: { organization_id: organization.id }, }) // Current member IDs from GitHub const currentMemberIds = members.map(m => `github|${m.id}`) // Remove members who no longer exist in the org for (const existingMember of existingMembersInDb) { if (!currentMemberIds.includes(existingMember.user_id)) { await organizationMemberRepository.removeMember(organization.id, existingMember.user_id) await deleteOrganizationMemberApiKeys(existingMember.user_id, organization.id) } } // Process each member: create or update user and add/update organization membership for (const member of members) { const userId = `github|${member.id}` const existingUser = await getUserById(userId) if (!existingUser) { await createOrUpdateUser(userId, member.username, null, null) } try { // Check if member already exists in organization_members const existingMember = await prisma.organization_members.findUnique({ where: { organization_id_user_id: { organization_id: organization.id, user_id: userId, }, }, }) if (!existingMember) { await organizationMemberRepository.addMember({ organizationId: organization.id, userId, role: member.role, }) } else if (existingMember.role !== member.role) { // Update role if changed await organizationMemberRepository.updateRole(organization.id, userId, member.role) } } catch (error) { // If error is due to unique constraint, skip if (error instanceof Error && error.message.includes("Unique constraint failed")) { // skip } else { throw error } } } // Process each repo for (const repo of repos) { // Upsert repository await upsertRepository({ github_repo_id: String(repo.id), installation_id: installation.id, name: repo.name, full_name: repo.full_name, is_private: repo.private, organization_id: organization.id, }) } } catch (error) { dependencies.console.error( `Error fetching data for org ${installation.account!.login}:`, error, ) Sentry.captureException(error) continue } } Sentry.captureMessage("syncOrgsWithMembers completed") }