diff --git a/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.test.ts b/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.test.ts index 7ebecf3b99e..450044e01ea 100644 --- a/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.test.ts +++ b/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.test.ts @@ -53,8 +53,14 @@ type IntegrationFixture = { owned_by_user_id: string | null; owned_by_organization_id: string | null; metadata: unknown; + repositories?: { id: number; name: string; full_name: string; private: boolean }[]; }; +type RepositoryCustomizationFixture = { + bot_mention_model_slug: string | null; + pr_review_mode: string | null; +} | null; + type RouteContext = { params: Promise<{ reviewId: string }>; }; @@ -65,6 +71,10 @@ const mockCreateTRPCContext = jest.fn<() => Promise>(); const mockCodeReviewsGet = jest.fn<(input: { reviewId: string }) => Promise>(); const mockGetIntegrationById = jest.fn<(integrationId: string) => Promise>(); +const mockGetRepositoryCustomization = + jest.fn< + (integrationId: string, repositoryId: string) => Promise + >(); const mockPersonalPrepareSession = jest.fn<(input: PrepareSessionInput) => Promise>(); const mockOrganizationPrepareSession = @@ -99,6 +109,7 @@ jest.mock('@/routers/root-router', () => ({ jest.mock('@/lib/integrations/db/platform-integrations', () => ({ getIntegrationById: mockGetIntegrationById, + getRepositoryCustomization: mockGetRepositoryCustomization, })); let getRoute: RouteGet; @@ -186,6 +197,7 @@ describe('GET /cloud-agent-fork/review/[reviewId]', () => { mockCreateTRPCContext.mockResolvedValue({ user: { id: USER_ID } }); mockSuccessfulReview(); mockGetIntegrationById.mockResolvedValue(makeIntegration()); + mockGetRepositoryCustomization.mockResolvedValue(null); mockPersonalPrepareSession.mockResolvedValue({ kiloSessionId: PERSONAL_KILO_SESSION_ID, cloudAgentSessionId: 'agent_personal', @@ -246,6 +258,52 @@ describe('GET /cloud-agent-fork/review/[reviewId]', () => { ); }); + it('uses the repository-level model override when the review repo has a customization', async () => { + mockGetIntegrationById.mockResolvedValue( + makeIntegration({ + repositories: [{ id: 42, name: 'repo', full_name: 'owner/repo', private: false }], + }) + ); + mockGetRepositoryCustomization.mockResolvedValue({ + bot_mention_model_slug: 'repo-override-model', + pr_review_mode: null, + }); + + const response = await requestReview(); + + expect(mockGetRepositoryCustomization).toHaveBeenCalledWith(REVIEW_INTEGRATION_ID, '42'); + expect(mockPersonalPrepareSession).toHaveBeenCalledWith({ + githubRepo: 'owner/repo', + prompt: buildFixReviewPrompt(PR_URL), + mode: DEFAULT_CODE_REVIEW_MODE, + model: 'repo-override-model', + autoInitiate: true, + autoCommit: false, + }); + expect(response.status).toBe(303); + }); + + it('falls back to the installation default model when the review repo is not in the cached list', async () => { + mockGetIntegrationById.mockResolvedValue( + makeIntegration({ + repositories: [{ id: 99, name: 'other', full_name: 'owner/other-repo', private: false }], + }) + ); + + const response = await requestReview(); + + expect(mockGetRepositoryCustomization).not.toHaveBeenCalled(); + expect(mockPersonalPrepareSession).toHaveBeenCalledWith({ + githubRepo: 'owner/repo', + prompt: buildFixReviewPrompt(PR_URL), + mode: DEFAULT_CODE_REVIEW_MODE, + model: CONFIGURED_BOT_MODEL, + autoInitiate: true, + autoCommit: false, + }); + expect(response.status).toBe(303); + }); + it('starts organization review fix sessions with the exact linked integration bot model', async () => { mockSuccessfulReview({ owned_by_user_id: null, diff --git a/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.ts b/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.ts index f8a79117ba7..7003e7f0177 100644 --- a/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.ts +++ b/apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.ts @@ -3,10 +3,12 @@ import { NextResponse } from 'next/server'; import { buildFixReviewPrompt } from '@/lib/code-reviews/prompts/fix-review-prompt'; import { DEFAULT_CODE_REVIEW_MODE } from '@/lib/code-reviews/core/constants'; import { resolveBotModelSlug } from '@/lib/bot/model'; +import { resolveModelForGitHubRepository } from '@/lib/integrations/github-repository-settings'; import { getIntegrationById } from '@/lib/integrations/db/platform-integrations'; import { createCallerFactory, createTRPCContext } from '@/lib/trpc/init'; import { rootRouter } from '@/routers/root-router'; import { TRPCError } from '@trpc/server'; +import { captureException } from '@sentry/nextjs'; import { z } from 'zod'; const createCaller = createCallerFactory(rootRouter); @@ -86,11 +88,21 @@ export async function GET(request: NextRequest, context: RouteContext) { return redirectToError(url.origin, 'fix_session_failed'); } + const model = integration + ? await resolveModelForGitHubRepository(integration, review.repo_full_name).catch(error => { + captureException(error, { + tags: { component: 'cloud-agent-fork', op: 'resolveModelForGitHubRepository' }, + extra: { reviewId, repoFullName: review.repo_full_name }, + }); + return resolveBotModelSlug(integration); + }) + : resolveBotModelSlug(integration); + const sessionInput = { githubRepo: review.repo_full_name, prompt: buildFixReviewPrompt(review.pr_url), mode: DEFAULT_CODE_REVIEW_MODE, - model: resolveBotModelSlug(integration), + model, autoInitiate: true, autoCommit: false, }; diff --git a/apps/web/src/lib/bot/tools/spawn-cloud-agent-session.test.ts b/apps/web/src/lib/bot/tools/spawn-cloud-agent-session.test.ts index ed815c6639b..950f9fa5276 100644 --- a/apps/web/src/lib/bot/tools/spawn-cloud-agent-session.test.ts +++ b/apps/web/src/lib/bot/tools/spawn-cloud-agent-session.test.ts @@ -11,6 +11,7 @@ import type { getGitLabInstanceUrlForUser as GetGitLabInstanceUrlForUser, getGitLabTokenForUser as GetGitLabTokenForUser, } from '@/lib/cloud-agent/gitlab-integration-helpers'; +import type { resolveModelForGitHubRepository as ResolveModelForGitHubRepository } from '@/lib/integrations/github-repository-settings'; import type SpawnCloudAgentSession from './spawn-cloud-agent-session'; jest.mock('@/lib/config.server', () => ({ @@ -38,6 +39,10 @@ jest.mock('@/lib/cloud-agent/gitlab-integration-helpers', () => ({ buildGitLabCloneUrl: jest.fn(), })); +jest.mock('@/lib/integrations/github-repository-settings', () => ({ + resolveModelForGitHubRepository: jest.fn(), +})); + jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn(), })); @@ -73,12 +78,16 @@ let mockGetGitHubTokenForUser: jest.MockedFunction let mockGetGitLabTokenForUser: jest.MockedFunction; let mockGetGitLabInstanceUrlForUser: jest.MockedFunction; let mockBuildGitLabCloneUrl: jest.MockedFunction; +let mockResolveModelForGitHubRepository: jest.MockedFunction< + typeof ResolveModelForGitHubRepository +>; describe('spawnCloudAgentSession delegation', () => { beforeAll(async () => { const client = await import('@/lib/cloud-agent-next/cloud-agent-client'); const github = await import('@/lib/cloud-agent/github-integration-helpers'); const gitlab = await import('@/lib/cloud-agent/gitlab-integration-helpers'); + const repositorySettings = await import('@/lib/integrations/github-repository-settings'); const spawn = await import('./spawn-cloud-agent-session'); mockCreateCloudAgentNextClient = jest.mocked(client.createCloudAgentNextClient); @@ -87,6 +96,9 @@ describe('spawnCloudAgentSession delegation', () => { mockGetGitLabTokenForUser = jest.mocked(gitlab.getGitLabTokenForUser); mockGetGitLabInstanceUrlForUser = jest.mocked(gitlab.getGitLabInstanceUrlForUser); mockBuildGitLabCloneUrl = jest.mocked(gitlab.buildGitLabCloneUrl); + mockResolveModelForGitHubRepository = jest.mocked( + repositorySettings.resolveModelForGitHubRepository + ); spawnCloudAgentSession = spawn.default; }); @@ -106,6 +118,7 @@ describe('spawnCloudAgentSession delegation', () => { mockGetGitLabTokenForUser.mockResolvedValue('gitlab-token'); mockGetGitLabInstanceUrlForUser.mockResolvedValue('https://gitlab.com'); mockBuildGitLabCloneUrl.mockReturnValue('https://gitlab.com/group/repo.git'); + mockResolveModelForGitHubRepository.mockResolvedValue('model'); }); it('delegates GitHub profile resolution while preserving repository and organization context', async () => { @@ -197,4 +210,59 @@ describe('spawnCloudAgentSession delegation', () => { ); } ); + + it('uses the per-repository model override resolved for the GitHub repo, not the raw model argument', async () => { + mockResolveModelForGitHubRepository.mockResolvedValue('repo-override-model'); + + await spawnCloudAgentSession( + { githubRepo: 'owner/repo', prompt: 'Use the files', mode: 'code' }, + 'installation-default-model', + userIntegration, + 'auth-token', + 'request-github-override', + undefined, + { chatPlatform: 'slack' } + ); + + expect(mockResolveModelForGitHubRepository).toHaveBeenCalledWith(userIntegration, 'owner/repo'); + expect(mockPrepareSession).toHaveBeenCalledWith( + expect.objectContaining({ model: 'repo-override-model' }) + ); + }); + + it('falls back to the incoming model when the per-repository model lookup rejects', async () => { + mockResolveModelForGitHubRepository.mockRejectedValue(new Error('customization query failed')); + + const result = await spawnCloudAgentSession( + { githubRepo: 'owner/repo', prompt: 'Use the files', mode: 'code' }, + 'installation-default-model', + userIntegration, + 'auth-token', + 'request-github-lookup-failure', + undefined, + { chatPlatform: 'slack' } + ); + + expect(mockPrepareSession).toHaveBeenCalledWith( + expect.objectContaining({ model: 'installation-default-model' }) + ); + expect(result.cloudAgentSessionId).toBe('cloud-session-1'); + }); + + it('does not resolve a per-repo model override for GitLab sessions', async () => { + await spawnCloudAgentSession( + { gitlabProject: 'group/repo', prompt: 'Use the files', mode: 'code' }, + 'installation-default-model', + userIntegration, + 'auth-token', + 'request-gitlab-no-override', + undefined, + { chatPlatform: 'slack' } + ); + + expect(mockResolveModelForGitHubRepository).not.toHaveBeenCalled(); + expect(mockPrepareSession).toHaveBeenCalledWith( + expect.objectContaining({ model: 'installation-default-model' }) + ); + }); }); diff --git a/apps/web/src/lib/bot/tools/spawn-cloud-agent-session.ts b/apps/web/src/lib/bot/tools/spawn-cloud-agent-session.ts index 9b80a567149..736b81ee994 100644 --- a/apps/web/src/lib/bot/tools/spawn-cloud-agent-session.ts +++ b/apps/web/src/lib/bot/tools/spawn-cloud-agent-session.ts @@ -21,6 +21,7 @@ import { CALLBACK_TOKEN_SECRET } from '@/lib/config.server'; import { parseBotCallbackStep } from '@/lib/bot/step-budget'; import { ownerFromIntegration } from '@/lib/integrations/core/owner'; import type { Owner } from '@/lib/integrations/core/types'; +import { resolveModelForGitHubRepository } from '@/lib/integrations/github-repository-settings'; import { createHmac } from 'crypto'; import { captureException } from '@sentry/nextjs'; import type { PlatformIntegration } from '@kilocode/db'; @@ -184,10 +185,37 @@ export default async function spawnCloudAgentSession( }; } else { // GitHub path: get token, use githubRepo/githubToken - const githubToken = + if (!args.githubRepo) { + // Unreachable given the guard above (one of githubRepo/gitlabProject + // is always set here), but keeps the repo-model lookup below type-safe. + return { response: 'Error: You must specify either a githubRepo or a gitlabProject.' }; + } + + // The token fetch and the per-repository model override lookup are + // independent of each other, so resolve them concurrently rather than + // paying for two sequential round trips. + const [githubToken, effectiveModel] = await Promise.all([ owner.type === 'org' - ? await getGitHubTokenForOrganization(owner.id) - : await getGitHubTokenForUser(owner.id); + ? getGitHubTokenForOrganization(owner.id) + : getGitHubTokenForUser(owner.id), + // A per-repository model override (`repository_customizations`) takes + // precedence over the installation-default `model` resolved earlier for + // this whole bot conversation — the repo is only known now that the LLM + // has picked one via this tool call. Guard this lookup independently so + // a customization-query failure falls back to the incoming `model` + // instead of aborting session creation entirely. + resolveModelForGitHubRepository(platformIntegration, args.githubRepo).catch(error => { + console.error( + '[KiloBot] Failed to resolve per-repository model override, falling back to installation model:', + error + ); + captureException(error, { + tags: { component: 'kilo-bot', op: 'resolve-model-for-github-repository' }, + extra: { botRequestId, githubRepo: args.githubRepo }, + }); + return model; + }), + ]); if (!githubToken) { return { @@ -200,7 +228,7 @@ export default async function spawnCloudAgentSession( githubRepo: args.githubRepo, prompt, mode, - model, + model: effectiveModel, githubToken, kilocodeOrganizationId, createdOnPlatform: chatPlatform, diff --git a/apps/web/src/lib/integrations/core/types.test.ts b/apps/web/src/lib/integrations/core/types.test.ts new file mode 100644 index 00000000000..0ee95cef1f6 --- /dev/null +++ b/apps/web/src/lib/integrations/core/types.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from '@jest/globals'; +import { findRepositoryIdByFullName } from './types'; +import type { PlatformRepository } from '@kilocode/db/schema-types'; + +const repositories: PlatformRepository[] = [ + { id: 1, name: 'cloud', full_name: 'kilocode/cloud', private: true }, + { id: 2, name: 'extension', full_name: 'kilocode/extension', private: false }, +]; + +describe('findRepositoryIdByFullName', () => { + it('returns the matching repository id', () => { + expect(findRepositoryIdByFullName(repositories, 'kilocode/cloud')).toBe(1); + }); + + it('matches case-insensitively', () => { + expect(findRepositoryIdByFullName(repositories, 'KiloCode/Cloud')).toBe(1); + }); + + it('returns null when there is no match', () => { + expect(findRepositoryIdByFullName(repositories, 'kilocode/missing')).toBeNull(); + }); + + it('returns null for a null repository list', () => { + expect(findRepositoryIdByFullName(null, 'kilocode/cloud')).toBeNull(); + }); + + it('returns null for an empty repository list', () => { + expect(findRepositoryIdByFullName([], 'kilocode/cloud')).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/integrations/core/types.ts b/apps/web/src/lib/integrations/core/types.ts index bab97daf8e8..149be6edd87 100644 --- a/apps/web/src/lib/integrations/core/types.ts +++ b/apps/web/src/lib/integrations/core/types.ts @@ -17,6 +17,23 @@ export function requireNumericPlatformRepositories( return repositories; } +/** + * Finds a cached repository's numeric ID by its "owner/repo" full name. + * Comparison is case-insensitive: GitHub repo full names are effectively + * case-insensitive, and callers get `fullName` from sources (stored review + * rows, LLM-echoed tool arguments) that aren't guaranteed to match the + * cached casing exactly. + */ +export function findRepositoryIdByFullName( + repositories: PlatformRepository[] | null, + fullName: string +): number | null { + const match = repositories?.find( + repository => repository.full_name.toLowerCase() === fullName.toLowerCase() + ); + return match?.id ?? null; +} + /** * Represents ownership of an integration * Can be either a user or an organization diff --git a/apps/web/src/lib/integrations/db/platform-integrations.test.ts b/apps/web/src/lib/integrations/db/platform-integrations.test.ts index 65c97ebfe63..5ea7021431f 100644 --- a/apps/web/src/lib/integrations/db/platform-integrations.test.ts +++ b/apps/web/src/lib/integrations/db/platform-integrations.test.ts @@ -13,6 +13,7 @@ import { deleteIntegrationForOwner, createPendingIntegration, findIntegrationByInstallationId, + getRepositoryCustomization, listRepositoryCustomizations, suspendIntegration, suspendIntegrationForOwner, @@ -1182,4 +1183,48 @@ describe('repository_customizations accessors', () => { expect(remaining).toHaveLength(0); }); + + test('getRepositoryCustomization returns null when the repository has no override', async () => { + const customization = await getRepositoryCustomization(integrationId, '1'); + + expect(customization).toBeNull(); + }); + + test('getRepositoryCustomization returns the matching row', async () => { + await upsertRepositoryCustomization(integrationId, '1', { + bot_mention_model_slug: 'model-a', + pr_review_mode: 'on', + }); + + const customization = await getRepositoryCustomization(integrationId, '1'); + + expect(customization).toMatchObject({ + platform_integration_id: integrationId, + repository_id: '1', + bot_mention_model_slug: 'model-a', + pr_review_mode: 'on', + }); + }); + + test("getRepositoryCustomization does not leak another integration's row for the same repository_id", async () => { + const [otherIntegration] = await db + .insert(platform_integrations) + .values({ + owned_by_organization_id: orgId, + platform: 'github', + integration_type: 'app', + platform_installation_id: `${installationId}-other-lookup`, + integration_status: 'active', + repository_access: 'all', + }) + .returning(); + + await upsertRepositoryCustomization(otherIntegration.id, '1', { + bot_mention_model_slug: 'other-integration-model', + }); + + const customization = await getRepositoryCustomization(integrationId, '1'); + + expect(customization).toBeNull(); + }); }); diff --git a/apps/web/src/lib/integrations/db/platform-integrations.ts b/apps/web/src/lib/integrations/db/platform-integrations.ts index 782e2a862c5..7fc930f05c3 100644 --- a/apps/web/src/lib/integrations/db/platform-integrations.ts +++ b/apps/web/src/lib/integrations/db/platform-integrations.ts @@ -4,6 +4,7 @@ import { repository_customizations, platform_integrations, type NewRepositoryCustomization, + type RepositoryCustomization, } from '@kilocode/db/schema'; import { eq, and, or, isNull, asc, desc, sql, ne } from 'drizzle-orm'; import type { @@ -1155,6 +1156,29 @@ export async function listRepositoryCustomizations(integrationId: string) { .where(eq(repository_customizations.platform_integration_id, integrationId)); } +/** + * Looks up a single repository's override row, or `null` if the repository + * has no override (it fully inherits the installation's defaults). + * `repositoryId` is the platform's repository identifier (GitHub's numeric + * ID stringified, GitLab's project ID, etc.). + */ +export async function getRepositoryCustomization( + integrationId: string, + repositoryId: string +): Promise { + const [row] = await db + .select() + .from(repository_customizations) + .where( + and( + eq(repository_customizations.platform_integration_id, integrationId), + eq(repository_customizations.repository_id, repositoryId) + ) + ); + + return row ?? null; +} + /** * Upserts a per-repository override, changing only the fields present in * `updates`. Pass `null` for a field to explicitly clear it back to diff --git a/apps/web/src/lib/integrations/github-repository-settings.test.ts b/apps/web/src/lib/integrations/github-repository-settings.test.ts index 8ee7511cf08..bcc62d4d4cc 100644 --- a/apps/web/src/lib/integrations/github-repository-settings.test.ts +++ b/apps/web/src/lib/integrations/github-repository-settings.test.ts @@ -1,5 +1,27 @@ -import { describe, expect, it } from '@jest/globals'; -import { resolveRepositorySettings } from './github-repository-settings'; +import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import type { PlatformIntegration, RepositoryCustomization } from '@kilocode/db/schema'; +import type { + resolveModelForGitHubRepository as ResolveModelForGitHubRepository, + resolveRepositorySettings as ResolveRepositorySettings, +} from './github-repository-settings'; + +const mockGetRepositoryCustomization = + jest.fn< + (integrationId: string, repositoryId: string) => Promise + >(); + +jest.mock('@/lib/integrations/db/platform-integrations', () => ({ + getRepositoryCustomization: mockGetRepositoryCustomization, +})); + +let resolveRepositorySettings: typeof ResolveRepositorySettings; +let resolveModelForGitHubRepository: typeof ResolveModelForGitHubRepository; + +beforeAll(async () => { + const mod = await import('./github-repository-settings'); + resolveRepositorySettings = mod.resolveRepositorySettings; + resolveModelForGitHubRepository = mod.resolveModelForGitHubRepository; +}); describe('resolveRepositorySettings', () => { it('uses the installation default model and review mode when there is no override', () => { @@ -41,3 +63,68 @@ describe('resolveRepositorySettings', () => { expect(settings.prReviewMode).toBe('on'); }); }); + +describe('resolveModelForGitHubRepository', () => { + const integration = { + id: 'integration-1', + metadata: { model_slug: 'installation-default-model' }, + repositories: [ + { id: 1, name: 'cloud', full_name: 'kilocode/cloud', private: true }, + { id: 2, name: 'extension', full_name: 'kilocode/extension', private: false }, + ], + } as unknown as PlatformIntegration; + + beforeEach(() => { + mockGetRepositoryCustomization.mockReset(); + }); + + it('returns the repository override model when one exists', async () => { + mockGetRepositoryCustomization.mockResolvedValue({ + bot_mention_model_slug: 'repo-override-model', + pr_review_mode: null, + } as RepositoryCustomization); + + const model = await resolveModelForGitHubRepository(integration, 'kilocode/cloud'); + + expect(model).toBe('repo-override-model'); + expect(mockGetRepositoryCustomization).toHaveBeenCalledWith('integration-1', '1'); + }); + + it('matches the repository full name case-insensitively', async () => { + mockGetRepositoryCustomization.mockResolvedValue({ + bot_mention_model_slug: 'repo-override-model', + pr_review_mode: null, + } as RepositoryCustomization); + + await resolveModelForGitHubRepository(integration, 'KiloCode/Cloud'); + + expect(mockGetRepositoryCustomization).toHaveBeenCalledWith('integration-1', '1'); + }); + + it('falls back to the installation default when the repository has no override', async () => { + mockGetRepositoryCustomization.mockResolvedValue(null); + + const model = await resolveModelForGitHubRepository(integration, 'kilocode/cloud'); + + expect(model).toBe('installation-default-model'); + }); + + it('falls back to the installation default when the repository is not in the cached list', async () => { + const model = await resolveModelForGitHubRepository(integration, 'kilocode/missing'); + + expect(model).toBe('installation-default-model'); + expect(mockGetRepositoryCustomization).not.toHaveBeenCalled(); + }); + + it('falls back to the installation default when the integration has no cached repositories', async () => { + const integrationWithoutRepos = { + ...integration, + repositories: null, + } as unknown as PlatformIntegration; + + const model = await resolveModelForGitHubRepository(integrationWithoutRepos, 'kilocode/cloud'); + + expect(model).toBe('installation-default-model'); + expect(mockGetRepositoryCustomization).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/lib/integrations/github-repository-settings.ts b/apps/web/src/lib/integrations/github-repository-settings.ts index b2c87e86414..72a033b0218 100644 --- a/apps/web/src/lib/integrations/github-repository-settings.ts +++ b/apps/web/src/lib/integrations/github-repository-settings.ts @@ -3,6 +3,11 @@ import { z } from 'zod'; import type { RepositoryCustomization, PlatformIntegration } from '@kilocode/db/schema'; import { RepositoryReviewMode } from '@kilocode/db/schema-types'; import { resolveBotModelSlug } from '@/lib/bot/model'; +import { getRepositoryCustomization } from '@/lib/integrations/db/platform-integrations'; +import { + findRepositoryIdByFullName, + requireNumericPlatformRepositories, +} from '@/lib/integrations/core/types'; const GitHubReviewModeSchema = z.enum( Object.values(RepositoryReviewMode) as [RepositoryReviewMode, ...RepositoryReviewMode[]] @@ -72,3 +77,27 @@ export function resolveRepositorySettings( return { modelSlug, prReviewMode }; } + +/** + * Resolves the effective bot-mention model for one GitHub repository, + * applying its `repository_customizations` override (if any) on top of the + * installation default. `repoFullName` is matched against the installation's + * cached repository list (`integration.repositories`) to recover the numeric + * ID that `repository_customizations.repository_id` is keyed on; a repo not + * found in that cache (e.g. stale sync, or access lost) falls back to the + * installation default rather than failing. + */ +export async function resolveModelForGitHubRepository( + integration: PlatformIntegration, + repoFullName: string +): Promise { + const repositories = requireNumericPlatformRepositories(integration.repositories); + const repositoryId = findRepositoryIdByFullName(repositories, repoFullName); + + const customization = + repositoryId != null + ? await getRepositoryCustomization(integration.id, String(repositoryId)) + : null; + + return resolveRepositorySettings(integration, customization).modelSlug; +}