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 950f9fa527..f04b9c11a2 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 @@ -2,18 +2,18 @@ import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals import type { PlatformIntegration } from '@kilocode/db'; import type { CloudAgentAttachments } from '@/lib/cloud-agent/constants'; import type { createCloudAgentNextClient as CreateCloudAgentNextClient } from '@/lib/cloud-agent-next/cloud-agent-client'; -import type { - getGitHubTokenForOrganization as GetGitHubTokenForOrganization, - getGitHubTokenForUser as GetGitHubTokenForUser, -} from '@/lib/cloud-agent/github-integration-helpers'; import type { buildGitLabCloneUrl as BuildGitLabCloneUrl, 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 { resolveGitHubRepositoryForOwner as ResolveGitHubRepositoryForOwner } from '@/lib/slack-bot/github-repository-context'; import type SpawnCloudAgentSession from './spawn-cloud-agent-session'; +const mockGetGitHubIntegrationById = + jest.fn<(...args: unknown[]) => Promise>(); + jest.mock('@/lib/config.server', () => ({ CALLBACK_TOKEN_SECRET: 'callback-secret', })); @@ -26,9 +26,12 @@ jest.mock('@/lib/cloud-agent-next/cloud-agent-client', () => ({ createCloudAgentNextClient: jest.fn(), })); -jest.mock('@/lib/cloud-agent/github-integration-helpers', () => ({ - getGitHubTokenForOrganization: jest.fn(), - getGitHubTokenForUser: jest.fn(), +jest.mock('@/lib/slack-bot/github-repository-context', () => ({ + resolveGitHubRepositoryForOwner: jest.fn(), +})); + +jest.mock('@/lib/integrations/db/platform-integrations', () => ({ + getGitHubIntegrationById: (...args: unknown[]) => mockGetGitHubIntegrationById(...args), })); jest.mock('@/lib/cloud-agent/gitlab-integration-helpers', () => ({ @@ -73,8 +76,9 @@ const mockPrepareSession = const mockInitiateFromPreparedSession = jest.fn<(input: unknown) => Promise>(); let spawnCloudAgentSession: typeof SpawnCloudAgentSession; let mockCreateCloudAgentNextClient: jest.MockedFunction; -let mockGetGitHubTokenForOrganization: jest.MockedFunction; -let mockGetGitHubTokenForUser: jest.MockedFunction; +let mockResolveGitHubRepositoryForOwner: jest.MockedFunction< + typeof ResolveGitHubRepositoryForOwner +>; let mockGetGitLabTokenForUser: jest.MockedFunction; let mockGetGitLabInstanceUrlForUser: jest.MockedFunction; let mockBuildGitLabCloneUrl: jest.MockedFunction; @@ -85,14 +89,15 @@ let mockResolveModelForGitHubRepository: jest.MockedFunction< 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 githubRepositoryContext = await import('@/lib/slack-bot/github-repository-context'); 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); - mockGetGitHubTokenForOrganization = jest.mocked(github.getGitHubTokenForOrganization); - mockGetGitHubTokenForUser = jest.mocked(github.getGitHubTokenForUser); + mockResolveGitHubRepositoryForOwner = jest.mocked( + githubRepositoryContext.resolveGitHubRepositoryForOwner + ); mockGetGitLabTokenForUser = jest.mocked(gitlab.getGitLabTokenForUser); mockGetGitLabInstanceUrlForUser = jest.mocked(gitlab.getGitLabInstanceUrlForUser); mockBuildGitLabCloneUrl = jest.mocked(gitlab.buildGitLabCloneUrl); @@ -113,8 +118,19 @@ describe('spawnCloudAgentSession delegation', () => { kiloSessionId: 'kilo-session-1', }); mockInitiateFromPreparedSession.mockResolvedValue({}); - mockGetGitHubTokenForOrganization.mockResolvedValue('organization-github-token'); - mockGetGitHubTokenForUser.mockResolvedValue('github-token'); + mockResolveGitHubRepositoryForOwner.mockResolvedValue({ + id: 1, + name: 'repo', + full_name: 'owner/repo', + private: true, + githubIntegrationId: 'github-association-1', + githubAppType: 'standard', + }); + mockGetGitHubIntegrationById.mockResolvedValue({ + ...userIntegration, + id: 'github-association-1', + repositories: [{ id: 1, name: 'repo', full_name: 'owner/repo', private: true }], + }); mockGetGitLabTokenForUser.mockResolvedValue('gitlab-token'); mockGetGitLabInstanceUrlForUser.mockResolvedValue('https://gitlab.com'); mockBuildGitLabCloneUrl.mockReturnValue('https://gitlab.com/group/repo.git'); @@ -138,7 +154,7 @@ describe('spawnCloudAgentSession delegation', () => { expect(prepareInput).toEqual( expect.objectContaining({ githubRepo: 'owner/repo', - githubToken: 'organization-github-token', + githubIntegrationId: 'github-association-1', kilocodeOrganizationId: 'organization-1', createdOnPlatform: 'slack', attachments, @@ -149,12 +165,17 @@ describe('spawnCloudAgentSession delegation', () => { }) ); expect(prepareInput).not.toHaveProperty('images'); + expect(prepareInput).not.toHaveProperty('githubToken'); for (const field of profileDerivedInlineFields) { expect(prepareInput).not.toHaveProperty(field); } expect(mockCreateCloudAgentNextClient).toHaveBeenCalledWith('auth-token', { skipBalanceCheck: true, }); + expect(mockGetGitHubIntegrationById).toHaveBeenCalledWith( + { type: 'org', id: 'organization-1' }, + 'github-association-1' + ); expect(mockInitiateFromPreparedSession).toHaveBeenCalledWith({ cloudAgentSessionId: 'cloud-session-1', }); @@ -164,6 +185,40 @@ describe('spawnCloudAgentSession delegation', () => { }); }); + it('rejects GitHub repositories outside the owner inventory', async () => { + mockResolveGitHubRepositoryForOwner.mockResolvedValue(null); + + await expect( + spawnCloudAgentSession( + { githubRepo: 'other/repo', prompt: 'Use the files', mode: 'code' }, + 'model', + organizationIntegration, + 'auth-token', + 'request-unknown' + ) + ).resolves.toEqual( + expect.objectContaining({ response: expect.stringContaining('not uniquely available') }) + ); + expect(mockPrepareSession).not.toHaveBeenCalled(); + }); + + it('rejects a repository when its selected association is foreign to the owner', async () => { + mockGetGitHubIntegrationById.mockResolvedValue(null); + + await expect( + spawnCloudAgentSession( + { githubRepo: 'owner/repo', prompt: 'Inspect it', mode: 'code' }, + 'model', + organizationIntegration, + 'auth-token', + 'request-foreign' + ) + ).resolves.toEqual( + expect.objectContaining({ response: expect.stringContaining('no longer available') }) + ); + expect(mockPrepareSession).not.toHaveBeenCalled(); + }); + it('delegates GitLab profile resolution while preserving canonical repository context', async () => { await spawnCloudAgentSession( { gitlabProject: 'group/repo', prompt: 'Use the files', mode: 'ask' }, @@ -208,6 +263,10 @@ describe('spawnCloudAgentSession delegation', () => { expect(mockPrepareSession).toHaveBeenCalledWith( expect.objectContaining({ createdOnPlatform: origin }) ); + expect(mockGetGitHubIntegrationById).toHaveBeenCalledWith( + { type: 'user', id: 'owner-1' }, + 'github-association-1' + ); } ); @@ -224,7 +283,10 @@ describe('spawnCloudAgentSession delegation', () => { { chatPlatform: 'slack' } ); - expect(mockResolveModelForGitHubRepository).toHaveBeenCalledWith(userIntegration, 'owner/repo'); + expect(mockResolveModelForGitHubRepository).toHaveBeenCalledWith( + expect.objectContaining({ id: 'github-association-1' }), + 'owner/repo' + ); expect(mockPrepareSession).toHaveBeenCalledWith( expect.objectContaining({ model: 'repo-override-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 736b81ee99..da0473cb6b 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 @@ -4,10 +4,6 @@ import { type PrepareSessionInput, } from '@/lib/cloud-agent-next/cloud-agent-client'; import type { RunSessionInput } from '@/lib/cloud-agent-next/run-session'; -import { - getGitHubTokenForOrganization, - getGitHubTokenForUser, -} from '@/lib/cloud-agent/github-integration-helpers'; import { getGitLabTokenForOrganization, getGitLabTokenForUser, @@ -27,6 +23,8 @@ import { captureException } from '@sentry/nextjs'; import type { PlatformIntegration } from '@kilocode/db'; import z from 'zod'; import { getBotUserId } from '@/lib/bot-users/bot-user-service'; +import { resolveGitHubRepositoryForOwner } from '@/lib/slack-bot/github-repository-context'; +import { getGitHubIntegrationById } from '@/lib/integrations/db/platform-integrations'; /** * Derive a per-request callback token so the dedicated callback HMAC secret @@ -184,52 +182,47 @@ export default async function spawnCloudAgentSession( attachments: options?.attachments, }; } else { - // GitHub path: get token, use githubRepo/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' - ? 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) { + const repository = await resolveGitHubRepositoryForOwner(owner, args.githubRepo); + if (!repository) { return { response: - 'Error: No GitHub token available. Please ensure a GitHub integration is connected in your Kilo Code settings.', + "Error: That GitHub repository is not uniquely available through this organization's approved GitHub connections.", + }; + } + + const githubIntegration = await getGitHubIntegrationById(owner, repository.githubIntegrationId); + if (!githubIntegration) { + return { + response: 'Error: That GitHub connection is no longer available to this Kilo organization.', }; } + const effectiveModel = await resolveModelForGitHubRepository( + githubIntegration, + 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; + }); prepareInput = { githubRepo: args.githubRepo, prompt, mode, model: effectiveModel, - githubToken, + githubIntegrationId: repository.githubIntegrationId, kilocodeOrganizationId, createdOnPlatform: chatPlatform, callbackTarget, diff --git a/apps/web/src/lib/integrations/github/sharing-compatibility.ts b/apps/web/src/lib/integrations/github/sharing-compatibility.ts index 44b92b23ec..ca179d63f7 100644 --- a/apps/web/src/lib/integrations/github/sharing-compatibility.ts +++ b/apps/web/src/lib/integrations/github/sharing-compatibility.ts @@ -117,11 +117,7 @@ export async function evaluateGitHubSharingCompatibility( .where( and( or(...slackOwnerConditions), - inArray(platform_integrations.platform, [ - PLATFORM.SLACK, - PLATFORM.DISCORD, - PLATFORM.LINEAR, - ]), + inArray(platform_integrations.platform, [PLATFORM.DISCORD, PLATFORM.LINEAR]), eq(platform_integrations.integration_status, INTEGRATION_STATUS.ACTIVE), isNull(platform_integrations.suspended_at), isNull(platform_integrations.auth_invalid_at) diff --git a/apps/web/src/lib/integrations/provider-oauth-attempts.test.ts b/apps/web/src/lib/integrations/provider-oauth-attempts.test.ts index 2fa574e8bc..225ddff3de 100644 --- a/apps/web/src/lib/integrations/provider-oauth-attempts.test.ts +++ b/apps/web/src/lib/integrations/provider-oauth-attempts.test.ts @@ -1,6 +1,6 @@ import { cleanupDbForTest, db } from '@/lib/drizzle'; -import { organizations, provider_oauth_attempts } from '@kilocode/db/schema'; -import { eq, sql } from 'drizzle-orm'; +import { organizations, platform_integrations, provider_oauth_attempts } from '@kilocode/db/schema'; +import { and, eq, sql } from 'drizzle-orm'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { createTestOrganization } from '@/tests/helpers/organization.helper'; import { @@ -113,11 +113,21 @@ describe('provider OAuth attempts', () => { await expect( connectVerifiedGitHubInstallation({ type: 'org', id: organizationA.id }, github) ).resolves.toMatchObject({ ok: true }); + await db.insert(platform_integrations).values({ + owned_by_organization_id: organizationB.id, + platform: 'slack', + integration_type: 'oauth', + platform_installation_id: 'T_EXISTING', + platform_account_id: 'T_EXISTING', + integration_status: 'active', + installed_at: new Date().toISOString(), + }); await beginProviderOAuthAttempt({ actorUserId: destinationUser.id, owner: { type: 'org', id: organizationB.id }, provider: 'slack', state: 'state-started', + purpose: 'provider_install', }); await expect( connectVerifiedGitHubInstallation( @@ -125,6 +135,44 @@ describe('provider OAuth attempts', () => { { ...github, kiloUserId: destinationUser.id } ) ).resolves.toEqual({ ok: false, reason: 'incompatible_workflow' }); + await cancelProviderOAuthAttempt({ + actorUserId: destinationUser.id, + owner: { type: 'org', id: organizationB.id }, + provider: 'slack', + state: 'state-started', + purpose: 'provider_install', + }); + for (const platform of ['linear', 'discord']) { + await db.insert(platform_integrations).values({ + owned_by_organization_id: organizationB.id, + platform, + integration_type: 'oauth', + platform_installation_id: `${platform}-existing`, + platform_account_id: `${platform}-existing`, + integration_status: 'active', + installed_at: new Date().toISOString(), + }); + await expect( + connectVerifiedGitHubInstallation( + { type: 'org', id: organizationB.id }, + { ...github, kiloUserId: destinationUser.id } + ) + ).resolves.toEqual({ ok: false, reason: 'incompatible_workflow' }); + await db + .delete(platform_integrations) + .where( + and( + eq(platform_integrations.platform, platform), + eq(platform_integrations.owned_by_organization_id, organizationB.id) + ) + ); + } + await expect( + connectVerifiedGitHubInstallation( + { type: 'org', id: organizationB.id }, + { ...github, kiloUserId: destinationUser.id } + ) + ).resolves.toMatchObject({ ok: true }); }); it('blocks provider start after shared GitHub attach', async () => { diff --git a/apps/web/src/lib/slack-bot/github-repository-context.test.ts b/apps/web/src/lib/slack-bot/github-repository-context.test.ts index 2df2e35bbf..6e8c523124 100644 --- a/apps/web/src/lib/slack-bot/github-repository-context.test.ts +++ b/apps/web/src/lib/slack-bot/github-repository-context.test.ts @@ -1,25 +1,245 @@ -import { getIntegrationForOwner } from '@/lib/integrations/db/platform-integrations'; -import { getGitHubRepositoryContext } from './github-repository-context'; +import { getAllIntegrationsForOwner } from '@/lib/integrations/db/platform-integrations'; +import { + getGitHubRepositoryContext, + resolveGitHubRepositoryForOwner, +} from './github-repository-context'; jest.mock('@/lib/integrations/db/platform-integrations', () => ({ - getIntegrationForOwner: jest.fn(), + getAllIntegrationsForOwner: jest.fn(), })); -test('does not expose cached repository context from a disconnected association', async () => { - jest.mocked(getIntegrationForOwner).mockResolvedValue({ - integration_status: 'suspended', - github_disconnected_at: '2026-09-07T00:00:00.000Z', - suspended_at: '2026-09-07T00:00:00.000Z', - auth_invalid_at: null, - platform_account_login: 'private-owner', - repository_access: 'selected', - repositories_synced_at: '2026-09-07T00:00:00.000Z', - repositories: [{ id: 1, name: 'private', full_name: 'private-owner/private', private: true }], - } as never); +jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn() })); + +test('lists the healthy sibling while excluding unhealthy GitHub associations', async () => { + jest.mocked(getAllIntegrationsForOwner).mockResolvedValue([ + { + id: 'association-suspended', + platform: 'github', + integration_status: 'suspended', + github_disconnected_at: '2026-09-07T00:00:00.000Z', + suspended_at: '2026-09-07T00:00:00.000Z', + auth_invalid_at: null, + platform_account_login: 'private-owner', + repository_access: 'selected', + repositories_synced_at: '2026-09-07T00:00:00.000Z', + repositories: [{ id: 1, name: 'private', full_name: 'private-owner/private', private: true }], + }, + { + id: 'association-auth-invalid', + platform: 'github', + integration_status: 'active', + github_disconnected_at: null, + suspended_at: null, + auth_invalid_at: '2026-09-07T00:00:00.000Z', + repositories: [{ id: 2, name: 'invalid', full_name: 'acme/invalid', private: true }], + }, + { + id: 'association-disconnected', + platform: 'github', + integration_status: 'active', + github_disconnected_at: '2026-09-07T00:00:00.000Z', + suspended_at: null, + auth_invalid_at: null, + repositories: [{ id: 3, name: 'gone', full_name: 'acme/gone', private: true }], + }, + { + id: 'association-healthy', + platform: 'github', + integration_status: 'active', + github_disconnected_at: null, + suspended_at: null, + auth_invalid_at: null, + repositories: [{ id: 4, name: 'healthy', full_name: 'acme/healthy', private: true }], + }, + ] as never); await expect(getGitHubRepositoryContext({ type: 'user', id: 'user-1' })).resolves.toEqual({ - accountLogin: null, - repositoryAccess: null, - repositoriesSyncedAt: null, - repositories: null, + repositories: [ + expect.objectContaining({ + full_name: 'acme/healthy', + githubIntegrationId: 'association-healthy', + }), + ], + }); + await expect( + resolveGitHubRepositoryForOwner({ type: 'user', id: 'user-1' }, 'acme/healthy') + ).resolves.toMatchObject({ githubIntegrationId: 'association-healthy' }); + await expect( + resolveGitHubRepositoryForOwner({ type: 'user', id: 'user-1' }, 'acme/invalid') + ).resolves.toBeNull(); +}); + +test('retains association provenance across repository choices', async () => { + jest.mocked(getAllIntegrationsForOwner).mockResolvedValue([ + { + id: 'association-a', + platform: 'github', + integration_status: 'active', + github_app_type: 'standard', + github_disconnected_at: null, + suspended_at: null, + auth_invalid_at: null, + repositories: [{ id: 1, name: 'api', full_name: 'alpha/api', private: true }], + }, + { + id: 'association-b', + platform: 'github', + integration_status: 'active', + github_app_type: 'lite', + github_disconnected_at: null, + suspended_at: null, + auth_invalid_at: null, + repositories: [{ id: 2, name: 'api', full_name: 'beta/api', private: true }], + }, + ] as never); + + await expect(getGitHubRepositoryContext({ type: 'org', id: 'organization-1' })).resolves.toEqual({ + repositories: [ + expect.objectContaining({ full_name: 'alpha/api', githubIntegrationId: 'association-a' }), + expect.objectContaining({ full_name: 'beta/api', githubIntegrationId: 'association-b' }), + ], }); + await expect( + resolveGitHubRepositoryForOwner({ type: 'org', id: 'organization-1' }, 'alpha/api') + ).resolves.toMatchObject({ + githubIntegrationId: 'association-a', + }); +}); + +test('rejects a repository exposed by multiple associations', async () => { + jest.mocked(getAllIntegrationsForOwner).mockResolvedValue([ + { + id: 'association-a', + platform: 'github', + integration_status: 'active', + github_disconnected_at: null, + suspended_at: null, + auth_invalid_at: null, + repositories: [{ id: 1, name: 'api', full_name: 'Shared/API', private: true }], + }, + { + id: 'association-b', + platform: 'github', + integration_status: 'active', + github_disconnected_at: null, + suspended_at: null, + auth_invalid_at: null, + repositories: [{ id: 2, name: 'api', full_name: 'shared/api', private: true }], + }, + ] as never); + + await expect( + resolveGitHubRepositoryForOwner({ type: 'org', id: 'organization-1' }, 'SHARED/api') + ).resolves.toBeNull(); +}); + +test('matches GitHub repository names case-insensitively', async () => { + jest.mocked(getAllIntegrationsForOwner).mockResolvedValue([ + { + id: 'association-a', + platform: 'github', + integration_status: 'active', + github_disconnected_at: null, + suspended_at: null, + auth_invalid_at: null, + repositories: [{ id: 1, name: 'Repo', full_name: 'Acme/Repo', private: true }], + }, + ] as never); + + await expect( + resolveGitHubRepositoryForOwner({ type: 'org', id: 'organization-1' }, 'acme/repo') + ).resolves.toMatchObject({ githubIntegrationId: 'association-a' }); +}); + +test('isolates malformed repository caches from healthy sibling associations', async () => { + jest.mocked(getAllIntegrationsForOwner).mockResolvedValue([ + { + id: 'association-string-id', + platform: 'github', + integration_status: 'active', + github_disconnected_at: null, + suspended_at: null, + auth_invalid_at: null, + repositories: [{ id: '1', name: 'bad', full_name: 'acme/bad', private: true }], + }, + { + id: 'association-non-array', + platform: 'github', + integration_status: 'active', + github_disconnected_at: null, + suspended_at: null, + auth_invalid_at: null, + repositories: { id: 2 }, + }, + { + id: 'association-null-entry', + platform: 'github', + integration_status: 'active', + github_disconnected_at: null, + suspended_at: null, + auth_invalid_at: null, + repositories: [null], + }, + { + id: 'association-malformed-fields', + platform: 'github', + integration_status: 'active', + github_disconnected_at: null, + suspended_at: null, + auth_invalid_at: null, + repositories: [{ id: 3, name: 'bad', full_name: 3, private: 'yes' }], + }, + { + id: 'association-healthy', + platform: 'github', + integration_status: 'active', + github_disconnected_at: null, + suspended_at: null, + auth_invalid_at: null, + repositories: [{ id: 4, name: 'good', full_name: 'acme/good', private: true }], + }, + ] as never); + + await expect(getGitHubRepositoryContext({ type: 'org', id: 'organization-1' })).resolves.toEqual({ + repositories: [ + expect.objectContaining({ + full_name: 'acme/good', + githubIntegrationId: 'association-healthy', + }), + ], + }); +}); + +test('keeps the same canonical repository associated to each Kilo owner separately', async () => { + const repository = { id: 7, name: 'shared', full_name: 'acme/shared', private: true }; + jest + .mocked(getAllIntegrationsForOwner) + .mockResolvedValueOnce([ + { + id: 'association-owner-a', + platform: 'github', + integration_status: 'active', + github_disconnected_at: null, + suspended_at: null, + auth_invalid_at: null, + repositories: [repository], + }, + ] as never) + .mockResolvedValueOnce([ + { + id: 'association-owner-b', + platform: 'github', + integration_status: 'active', + github_disconnected_at: null, + suspended_at: null, + auth_invalid_at: null, + repositories: [repository], + }, + ] as never); + + await expect( + resolveGitHubRepositoryForOwner({ type: 'org', id: 'owner-a' }, repository.full_name) + ).resolves.toMatchObject({ githubIntegrationId: 'association-owner-a' }); + await expect( + resolveGitHubRepositoryForOwner({ type: 'org', id: 'owner-b' }, repository.full_name) + ).resolves.toMatchObject({ githubIntegrationId: 'association-owner-b' }); }); diff --git a/apps/web/src/lib/slack-bot/github-repository-context.ts b/apps/web/src/lib/slack-bot/github-repository-context.ts index 19cc364f4a..c9b50360bf 100644 --- a/apps/web/src/lib/slack-bot/github-repository-context.ts +++ b/apps/web/src/lib/slack-bot/github-repository-context.ts @@ -1,73 +1,92 @@ -import { - requireNumericPlatformRepositories, - type Owner, - type PlatformRepository, -} from '@/lib/integrations/core/types'; +import { type Owner, type PlatformRepository } from '@/lib/integrations/core/types'; import { PLATFORM } from '@/lib/integrations/core/constants'; -import { getIntegrationForOwner } from '@/lib/integrations/db/platform-integrations'; +import { getAllIntegrationsForOwner } from '@/lib/integrations/db/platform-integrations'; import { isPlatformIntegrationHealthy } from '@/lib/integrations/core/health'; +import { captureException } from '@sentry/nextjs'; +import { z } from 'zod'; + +const GitHubRepositoryCacheSchema = z + .array( + z + .object({ + id: z.number().int(), + name: z.string(), + full_name: z.string(), + private: z.boolean(), + default_branch: z.string().optional(), + }) + .strict() + ) + .nullable(); export type GitHubRepositoryContext = { - accountLogin: string | null; - repositoryAccess: string | null; - repositoriesSyncedAt: string | null; - repositories: PlatformRepository[] | null; + repositories: GitHubRepositoryChoice[] | null; +}; + +export type GitHubRepositoryChoice = PlatformRepository & { + githubIntegrationId: string; + githubAppType: 'standard' | 'lite'; }; -/** - * Get GitHub repository context for an owner from their GitHub integration. - * This does not perform extra API requests; it uses data stored on the integration row. - */ export async function getGitHubRepositoryContext(owner: Owner): Promise { - const integration = await getIntegrationForOwner(owner, PLATFORM.GITHUB); - if (!isPlatformIntegrationHealthy(integration) || integration.integration_status !== 'active') { - return { - accountLogin: null, - repositoryAccess: null, - repositoriesSyncedAt: null, - repositories: null, - }; - } + const integrations = await getAllIntegrationsForOwner(owner); + const repositories = integrations.flatMap(integration => { + if ( + integration.platform !== PLATFORM.GITHUB || + integration.integration_status !== 'active' || + !isPlatformIntegrationHealthy(integration) + ) { + return []; + } + + const parsedRepositories = GitHubRepositoryCacheSchema.safeParse( + integration.repositories ?? null + ); + if (!parsedRepositories.success) { + captureException(new Error('Invalid cached GitHub repository inventory'), { + tags: { component: 'slack-bot', op: 'parse-github-repository-cache' }, + extra: { integrationId: integration.id }, + }); + return []; + } - const repositories = requireNumericPlatformRepositories(integration.repositories); + return (parsedRepositories.data ?? []).map(repository => ({ + ...repository, + githubIntegrationId: integration.id, + githubAppType: integration.github_app_type ?? 'standard', + })); + }); - return { - accountLogin: integration.platform_account_login, - repositoryAccess: integration.repository_access, - repositoriesSyncedAt: integration.repositories_synced_at, - repositories, - }; + return { repositories: repositories.length > 0 ? repositories : null }; } -export function formatGitHubRepositoriesForPrompt(context: GitHubRepositoryContext): string { - const headerLines: string[] = ['\n\nGitHub repository context for this workspace:']; +export async function resolveGitHubRepositoryForOwner( + owner: Owner, + fullName: string +): Promise { + const context = await getGitHubRepositoryContext(owner); + const normalizedFullName = fullName.toLowerCase(); + const matches = + context.repositories?.filter( + repository => repository.full_name.toLowerCase() === normalizedFullName + ) ?? []; - if (context.accountLogin) { - headerLines.push(`- Installation account: ${context.accountLogin}`); - } - if (context.repositoryAccess) { - headerLines.push(`- Repository access: ${context.repositoryAccess}`); - } - if (context.repositoriesSyncedAt) { - headerLines.push(`- Repositories synced at: ${context.repositoriesSyncedAt}`); - } + return matches.length === 1 ? matches[0] : null; +} - const header = headerLines.join('\n'); +export function formatGitHubRepositoriesForPrompt(context: GitHubRepositoryContext): string { + const header = '\n\nGitHub repository context for this workspace:'; if (!context.repositories || context.repositories.length === 0) { - if (context.repositoryAccess === 'all') { - return `${header} -- Repository list: not stored for "all" access (no repo list to show without extra requests). - -When the user asks you to work on code, ask them to specify the repository explicitly in owner/repo format.`; - } - return `${header} -- No GitHub repositories are currently connected. The user will need to specify a repository manually.`; +- No GitHub repositories are currently available for this Kilo organization.`; } const repoList = context.repositories - .map(repo => `- ${repo.full_name}${repo.private ? ' (private)' : ''} [id: ${repo.id}]`) + .map( + repo => + `- ${repo.full_name}${repo.private ? ' (private)' : ''} [id: ${repo.id}; association: ${repo.githubIntegrationId}; app: ${repo.githubAppType}]` + ) .join('\n'); return `${header}