Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
};
Expand All @@ -65,6 +71,10 @@ const mockCreateTRPCContext = jest.fn<() => Promise<TrpcContextFixture>>();
const mockCodeReviewsGet = jest.fn<(input: { reviewId: string }) => Promise<ReviewResult>>();
const mockGetIntegrationById =
jest.fn<(integrationId: string) => Promise<IntegrationFixture | null>>();
const mockGetRepositoryCustomization =
jest.fn<
(integrationId: string, repositoryId: string) => Promise<RepositoryCustomizationFixture>
>();
const mockPersonalPrepareSession =
jest.fn<(input: PrepareSessionInput) => Promise<PrepareSessionOutput>>();
const mockOrganizationPrepareSession =
Expand Down Expand Up @@ -99,6 +109,7 @@ jest.mock('@/routers/root-router', () => ({

jest.mock('@/lib/integrations/db/platform-integrations', () => ({
getIntegrationById: mockGetIntegrationById,
getRepositoryCustomization: mockGetRepositoryCustomization,
}));

let getRoute: RouteGet;
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 13 additions & 1 deletion apps/web/src/app/cloud-agent-fork/review/[reviewId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
};
Expand Down
68 changes: 68 additions & 0 deletions apps/web/src/lib/bot/tools/spawn-cloud-agent-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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(),
}));
Expand Down Expand Up @@ -73,12 +78,16 @@ let mockGetGitHubTokenForUser: jest.MockedFunction<typeof GetGitHubTokenForUser>
let mockGetGitLabTokenForUser: jest.MockedFunction<typeof GetGitLabTokenForUser>;
let mockGetGitLabInstanceUrlForUser: jest.MockedFunction<typeof GetGitLabInstanceUrlForUser>;
let mockBuildGitLabCloneUrl: jest.MockedFunction<typeof BuildGitLabCloneUrl>;
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);
Expand All @@ -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;
});

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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' })
);
});
});
36 changes: 32 additions & 4 deletions apps/web/src/lib/bot/tools/spawn-cloud-agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand All @@ -200,7 +228,7 @@ export default async function spawnCloudAgentSession(
githubRepo: args.githubRepo,
prompt,
mode,
model,
model: effectiveModel,
githubToken,
kilocodeOrganizationId,
createdOnPlatform: chatPlatform,
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/lib/integrations/core/types.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
17 changes: 17 additions & 0 deletions apps/web/src/lib/integrations/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading