From 4c6abe612df87a4269d1dcbceca6cbb70132e7ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 29 Aug 2026 12:39:36 +0200 Subject: [PATCH 1/3] feat(provider-review): preserve exact discovery producer identity --- .../github-integration-helpers.test.ts | 192 ++++++++- .../cloud-agent/github-integration-helpers.ts | 107 ++++- .../gitlab-integration-helpers.test.ts | 378 +++++++++++++++++- .../cloud-agent/gitlab-integration-helpers.ts | 306 +++++++------- .../db/platform-integrations.test.ts | 265 +++++++++++- .../integrations/db/platform-integrations.ts | 23 +- .../src/lib/integrations/gitlab-service.ts | 116 ++++-- .../platforms/github/adapter.test.ts | 164 ++++++++ .../integrations/platforms/github/adapter.ts | 13 +- 9 files changed, 1323 insertions(+), 241 deletions(-) create mode 100644 apps/web/src/lib/integrations/platforms/github/adapter.test.ts diff --git a/apps/web/src/lib/cloud-agent/github-integration-helpers.test.ts b/apps/web/src/lib/cloud-agent/github-integration-helpers.test.ts index ae735ca83f..f1e994f5a5 100644 --- a/apps/web/src/lib/cloud-agent/github-integration-helpers.test.ts +++ b/apps/web/src/lib/cloud-agent/github-integration-helpers.test.ts @@ -29,6 +29,7 @@ const mockCheckExistingFork = // Wire up the mocks jest.mock('@/lib/integrations/db/platform-integrations', () => ({ + getGitHubIntegrationById: jest.fn(), getIntegrationForOrganization: mockGetIntegrationForOrganization, getIntegrationForOwner: mockGetIntegrationForOwner, getPrimaryGitHubIntegrationForOrganization: mockGetPrimaryGitHubIntegrationForOrganization, @@ -37,6 +38,7 @@ jest.mock('@/lib/integrations/db/platform-integrations', () => ({ })); jest.mock('@/lib/integrations/platforms/github/adapter', () => ({ + fetchGitHubBranches: jest.fn(), fetchGitHubRepositories: mockFetchGitHubRepositories, generateGitHubInstallationToken: mockGenerateGitHubInstallationToken, checkExistingFork: mockCheckExistingFork, @@ -78,7 +80,7 @@ describe('github-integration-helpers', () => { expect(result.integrationInstalled).toBe(true); expect(result.repositories).toEqual([ - { id: 1, name: 'repo', fullName: 'org/repo', private: false }, + expect.objectContaining({ id: 1, name: 'repo', fullName: 'org/repo', private: false }), ]); expect(mockFetchGitHubRepositories).not.toHaveBeenCalled(); }); @@ -148,7 +150,7 @@ describe('github-integration-helpers', () => { expect(result.integrationInstalled).toBe(true); expect(result.repositories).toEqual([ - { id: 2, name: 'fresh', fullName: 'org/fresh', private: true }, + expect.objectContaining({ id: 2, name: 'fresh', fullName: 'org/fresh', private: true }), ]); expect(mockUpdateRepositoriesForIntegration).toHaveBeenCalledWith('integration-1', [ { id: 2, name: 'fresh', full_name: 'org/fresh', private: true }, @@ -166,13 +168,13 @@ describe('github-integration-helpers', () => { expect(result.integrationInstalled).toBe(true); expect(result.repositories).toEqual([ - { + expect.objectContaining({ id: 1, name: 'repo', fullName: 'org/repo', private: false, platformIntegrationId: 'integration-1', - }, + }), ]); expect(mockFetchGitHubRepositories).not.toHaveBeenCalled(); }); @@ -320,4 +322,186 @@ describe('github-integration-helpers', () => { expect(mockUpdateRepositoriesForIntegration).not.toHaveBeenCalled(); }); }); + + describe('discovery identity and branches', () => { + const integrationId = '11111111-1111-4111-8111-111111111111'; + const userOwner = { type: 'user' as const, id: 'oauth/user' }; + const orgOwner = { type: 'org' as const, id: '22222222-2222-4222-8222-222222222222' }; + const repository = { + id: 42, + name: 'API', + full_name: 'acme/API', + private: true, + default_branch: 'release/Case', + }; + const expectedRepository = { + provider: 'github' as const, + repositoryId: '42', + instanceUrl: 'https://github.com', + fullName: 'acme/API', + defaultBranch: 'release/Case', + }; + + it.each( + ['personal', 'primary', 'all'].flatMap(context => + [false, true].map(fresh => ({ context, fresh })) + ) + )( + 'preserves defaults and producing identity for $context, fresh=$fresh', + async ({ context, fresh }) => { + const integration = buildIntegration({ id: integrationId, repositories: [repository] }); + mockGetIntegrationForOwner.mockResolvedValue(integration); + mockGetPrimaryGitHubIntegrationForOrganization.mockResolvedValue(integration); + mockGetIntegrationsByOrganization.mockResolvedValue([integration]); + mockFetchGitHubRepositories.mockResolvedValue([repository]); + const helpers = await import('./github-integration-helpers'); + const result = + context === 'personal' + ? await helpers.fetchGitHubRepositoriesForUser(userOwner.id, fresh) + : context === 'primary' + ? await helpers.fetchGitHubRepositoriesForOrganization(orgOwner.id, fresh) + : await helpers.fetchAllGitHubRepositoriesForOrganization(orgOwner.id, fresh); + expect(result.repositories[0]).toMatchObject({ + id: 42, + fullName: 'acme/API', + defaultBranch: 'release/Case', + platformIntegrationId: integrationId, + repositoryReference: { + repository: expectedRepository, + authorization: { + kind: 'ownerIntegration', + owner: context === 'personal' ? userOwner : orgOwner, + integrationId, + }, + }, + }); + } + ); + + it('does not invent a default for an old Personal cache row', async () => { + mockGetIntegrationForOwner.mockResolvedValue(buildIntegration({ id: integrationId })); + const { fetchGitHubRepositoriesForUser } = await import('./github-integration-helpers'); + const result = await fetchGitHubRepositoriesForUser(userOwner.id); + expect(result.repositories[0].repositoryReference.repository.defaultBranch).toBeNull(); + }); + + it('keeps same-name repositories distinct across installations', async () => { + mockGetIntegrationsByOrganization.mockResolvedValue([ + buildIntegration({ id: integrationId, repositories: [repository] }), + buildIntegration({ + id: '33333333-3333-4333-8333-333333333333', + repositories: [repository], + }), + ]); + const { fetchAllGitHubRepositoriesForOrganization } = + await import('./github-integration-helpers'); + const result = await fetchAllGitHubRepositoriesForOrganization(orgOwner.id); + expect( + result.repositories.map(row => row.repositoryReference.authorization.integrationId) + ).toEqual([integrationId, '33333333-3333-4333-8333-333333333333']); + }); + + it('keeps the producing integration when replacement occurs during a fresh fetch', async () => { + mockGetIntegrationForOwner.mockResolvedValue(buildIntegration({ id: integrationId })); + mockFetchGitHubRepositories.mockImplementation(async () => { + mockGetIntegrationForOwner.mockResolvedValue(buildIntegration({ id: 'replacement' })); + return [repository]; + }); + const { fetchGitHubRepositoriesForUser } = await import('./github-integration-helpers'); + const result = await fetchGitHubRepositoriesForUser(userOwner.id, true); + expect(result.repositories[0].repositoryReference.authorization).toEqual({ + kind: 'ownerIntegration', + owner: userOwner, + integrationId, + }); + }); + + it.each([userOwner, orgOwner])( + 'uses the provider default and preserves branch case for $type', + async owner => { + const lookups = jest.requireMock<{ + getGitHubIntegrationById: jest.Mock< + (owner: Owner, id: string) => Promise + >; + }>('@/lib/integrations/db/platform-integrations'); + lookups.getGitHubIntegrationById.mockImplementation(async (actualOwner, id) => + actualOwner.id === owner.id && id === integrationId + ? buildIntegration({ id: integrationId, repositories: [repository] }) + : null + ); + const adapter = jest.requireMock<{ + fetchGitHubBranches: jest.Mock<() => Promise<{ name: string; isDefault: boolean }[]>>; + }>('@/lib/integrations/platforms/github/adapter'); + adapter.fetchGitHubBranches.mockResolvedValue([ + { name: 'feature/Case', isDefault: false }, + { name: 'release/Case', isDefault: true }, + ]); + const { listGitHubRepositoryBranches } = await import('./github-integration-helpers'); + await expect( + listGitHubRepositoryBranches(owner, { + repository: expectedRepository, + authorization: { kind: 'ownerIntegration', owner, integrationId }, + }) + ).resolves.toEqual({ + branches: [ + { name: 'feature/Case', isDefault: false }, + { name: 'release/Case', isDefault: true }, + ], + defaultBranch: 'release/Case', + nextCursor: null, + }); + } + ); + + it.each(['owner', 'integration', 'repository', 'instance'] as const)( + 'rejects a changed %s without selecting a same-name repository', + async change => { + const lookups = jest.requireMock<{ + getGitHubIntegrationById: jest.Mock< + (owner: Owner, id: string) => Promise + >; + }>('@/lib/integrations/db/platform-integrations'); + lookups.getGitHubIntegrationById.mockImplementation(async (_owner, id) => + id === integrationId + ? buildIntegration({ id: integrationId, repositories: [repository] }) + : null + ); + const { listGitHubRepositoryBranches } = await import('./github-integration-helpers'); + await expect( + listGitHubRepositoryBranches(userOwner, { + repository: { + ...expectedRepository, + ...(change === 'repository' ? { repositoryId: '999' } : {}), + ...(change === 'instance' ? { instanceUrl: 'https://other.test' } : {}), + }, + authorization: { + kind: 'ownerIntegration', + owner: change === 'owner' ? orgOwner : userOwner, + integrationId: change === 'integration' ? 'stale' : integrationId, + }, + }) + ).rejects.toMatchObject({ code: change === 'owner' ? 'FORBIDDEN' : 'NOT_FOUND' }); + } + ); + + it('returns an empty branch list without guessing main', async () => { + const lookups = jest.requireMock<{ + getGitHubIntegrationById: jest.Mock<() => Promise>; + }>('@/lib/integrations/db/platform-integrations'); + lookups.getGitHubIntegrationById.mockResolvedValue( + buildIntegration({ id: integrationId, repositories: [repository] }) + ); + const adapter = jest.requireMock<{ fetchGitHubBranches: jest.Mock<() => Promise> }>( + '@/lib/integrations/platforms/github/adapter' + ); + adapter.fetchGitHubBranches.mockResolvedValue([]); + const { listGitHubRepositoryBranches } = await import('./github-integration-helpers'); + await expect( + listGitHubRepositoryBranches(userOwner, { + repository: expectedRepository, + authorization: { kind: 'ownerIntegration', owner: userOwner, integrationId }, + }) + ).resolves.toEqual({ branches: [], defaultBranch: null, nextCursor: null }); + }); + }); }); diff --git a/apps/web/src/lib/cloud-agent/github-integration-helpers.ts b/apps/web/src/lib/cloud-agent/github-integration-helpers.ts index 91329a3f82..fdcb12ee61 100644 --- a/apps/web/src/lib/cloud-agent/github-integration-helpers.ts +++ b/apps/web/src/lib/cloud-agent/github-integration-helpers.ts @@ -22,6 +22,11 @@ import { type PlatformRepository, } from '@/lib/integrations/core/types'; +import type { + LaunchRepositoryReference, + Owner, +} from '@kilocode/app-shared/code-review/repository-identity'; + type GitHubRepositoriesResult = { integrationInstalled: boolean; repositories: { @@ -29,8 +34,11 @@ type GitHubRepositoriesResult = { name: string; fullName: string; private: boolean; - platformIntegrationId?: string; + defaultBranch?: string; + platformIntegrationId: string; platformAccountLogin?: string; + instanceUrl: string; + repositoryReference: LaunchRepositoryReference; }[]; syncedAt?: string | null; errorMessage?: string; @@ -38,19 +46,30 @@ type GitHubRepositoriesResult = { const mapRepositories = ( repositories: PlatformRepository[], - integration?: { id: string; platform_account_login: string | null } + integration: { id: string; platform_account_login: string | null }, + owner: Owner ): GitHubRepositoriesResult['repositories'] => { return repositories.map(repo => ({ id: repo.id, name: repo.name, fullName: repo.full_name, private: repo.private, - ...(integration - ? { - platformIntegrationId: integration.id, - platformAccountLogin: integration.platform_account_login ?? undefined, - } - : {}), + defaultBranch: repo.default_branch, + platformIntegrationId: integration.id, + platformAccountLogin: integration.platform_account_login ?? undefined, + instanceUrl: 'https://github.com', + repositoryReference: { + repository: { + provider: 'github', + instanceUrl: 'https://github.com', + repositoryId: String(repo.id), + fullName: repo.full_name, + // Old cache rows omit defaults. Remove this unavailable fallback only after + // old rows/clients disappear and the 30-day ledger window expires. + defaultBranch: repo.default_branch ?? null, + }, + authorization: { kind: 'ownerIntegration', owner, integrationId: integration.id }, + }, })); }; @@ -137,6 +156,7 @@ export async function fetchGitHubRepositoriesForOrganization( organizationId: string, forceRefresh: boolean = false ): Promise { + const owner: Owner = { type: 'org', id: organizationId }; const integration = await getPrimaryGitHubIntegrationForOrganization(organizationId); if (!integration) { @@ -170,13 +190,13 @@ export async function fetchGitHubRepositoriesForOrganization( await updateRepositoriesForIntegration(integration.id, repositories); return { integrationInstalled: true, - repositories: mapRepositories(repositories), + repositories: mapRepositories(repositories, integration, owner), syncedAt: new Date().toISOString(), }; } return { integrationInstalled: true, - repositories: mapRepositories(cachedRepositories), + repositories: mapRepositories(cachedRepositories, integration, owner), syncedAt: integration.repositories_synced_at, }; } catch (_error) { @@ -194,12 +214,16 @@ export async function fetchAllGitHubRepositoriesForOrganization( const integrations = ( await getIntegrationsByOrganization(organizationId, PLATFORM.GITHUB) ).filter(isPlatformIntegrationHealthy); - return fetchRepositoriesForIntegrations(integrations, forceRefresh); + return fetchRepositoriesForIntegrations(integrations, forceRefresh, { + type: 'org', + id: organizationId, + }); } async function fetchRepositoriesForIntegrations( integrations: Awaited>, - forceRefresh: boolean + forceRefresh: boolean, + owner: Owner ): Promise { if (integrations.length === 0) { return missingIntegrationResponse('No GitHub integration found for this organization'); @@ -217,12 +241,12 @@ async function fetchRepositoriesForIntegrations( ); await updateRepositoriesForIntegration(integration.id, repositories); return { - repositories: mapRepositories(repositories, integration), + repositories: mapRepositories(repositories, integration, owner), syncedAt: new Date().toISOString(), }; } return { - repositories: mapRepositories(cachedRepositories, integration), + repositories: mapRepositories(cachedRepositories, integration, owner), syncedAt: integration.repositories_synced_at, }; }) @@ -254,6 +278,7 @@ export async function fetchGitHubRepositoriesForUser( userId: string, forceRefresh: boolean = false ): Promise { + const owner: Owner = { type: 'user', id: userId }; const integration = await getIntegrationForOwner({ type: 'user', id: userId }, PLATFORM.GITHUB); if (!integration) { @@ -280,7 +305,7 @@ export async function fetchGitHubRepositoriesForUser( await updateRepositoriesForIntegration(integration.id, repositories); return { integrationInstalled: true, - repositories: mapRepositories(repositories), + repositories: mapRepositories(repositories, integration, owner), syncedAt: new Date().toISOString(), }; } @@ -288,7 +313,7 @@ export async function fetchGitHubRepositoriesForUser( // Return cached repos return { integrationInstalled: true, - repositories: mapRepositories(cachedRepositories), + repositories: mapRepositories(cachedRepositories, integration, owner), syncedAt: integration.repositories_synced_at, }; } catch (_error) { @@ -299,6 +324,56 @@ export async function fetchGitHubRepositoriesForUser( } } +export async function listGitHubRepositoryBranches( + owner: Owner, + reference: LaunchRepositoryReference +) { + const { repository, authorization } = reference; + if ( + repository.provider !== 'github' || + authorization.owner.type !== owner.type || + authorization.owner.id !== owner.id + ) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Repository owner does not match' }); + } + if (repository.instanceUrl !== 'https://github.com') { + throw new TRPCError({ code: 'NOT_FOUND', message: 'GitHub repository not found' }); + } + const { getGitHubIntegrationById } = await import('@/lib/integrations/db/platform-integrations'); + const integration = await getGitHubIntegrationById(owner, authorization.integrationId); + if ( + !integration || + !isPlatformIntegrationHealthy(integration) || + !integration.platform_installation_id + ) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'GitHub integration not found' }); + } + const repositories = + requireNumericPlatformRepositories(integration.repositories) ?? + (await fetchGitHubRepositories( + integration.platform_installation_id, + integration.github_app_type || 'standard' + )); + const selected = repositories.find( + candidate => + String(candidate.id) === repository.repositoryId && + candidate.full_name.toLowerCase() === repository.fullName.toLowerCase() + ); + if (!selected) throw new TRPCError({ code: 'NOT_FOUND', message: 'GitHub repository not found' }); + const { fetchGitHubBranches } = await import('@/lib/integrations/platforms/github/adapter'); + const branches = await fetchGitHubBranches( + integration.platform_installation_id, + selected.full_name, + integration.github_app_type || 'standard', + repository.repositoryId + ); + return { + branches, + defaultBranch: branches.find(branch => branch.isDefault)?.name ?? null, + nextCursor: null, + }; +} + export async function validateGitHubRepoAccessForUser( userId: string, githubRepo: string diff --git a/apps/web/src/lib/cloud-agent/gitlab-integration-helpers.test.ts b/apps/web/src/lib/cloud-agent/gitlab-integration-helpers.test.ts index cec31a2f19..dc59b86b58 100644 --- a/apps/web/src/lib/cloud-agent/gitlab-integration-helpers.test.ts +++ b/apps/web/src/lib/cloud-agent/gitlab-integration-helpers.test.ts @@ -1,10 +1,20 @@ -import { describe, expect, it, jest, beforeEach } from '@jest/globals'; +import { describe, expect, it, jest, beforeEach, beforeAll } from '@jest/globals'; import type { PlatformIntegration } from '@kilocode/db/schema'; import type { Owner } from '@/lib/integrations/core/types'; -import { buildGitLabCloneUrl } from './gitlab-integration-helpers'; +import type * as GitLabHelpers from './gitlab-integration-helpers'; +import type * as GitLabService from '@/lib/integrations/gitlab-service'; +import type { updateRepositoriesForIntegration } from '@/lib/integrations/db/platform-integrations'; +import type { fetchGitLabBranches } from '@/lib/integrations/platforms/gitlab/adapter'; +import type { fetchGitLabCredential } from '@/lib/integrations/platforms/gitlab/credential-broker-client'; + +let buildGitLabCloneUrl: typeof GitLabHelpers.buildGitLabCloneUrl; +beforeAll(async () => { + ({ buildGitLabCloneUrl } = await import('./gitlab-integration-helpers')); +}); // Define mock functions at module level with proper typing -const mockGetGitLabIntegration = jest.fn<(owner: Owner) => Promise>(); +const mockGetGitLabIntegration = + jest.fn<(owner: Owner, integrationId?: string) => Promise>(); const mockGetValidGitLabToken = jest.fn< ( @@ -16,15 +26,30 @@ const mockGetIntegrationForOrganization = jest.fn<(organizationId: string, platform: string) => Promise>(); const mockGetIntegrationForOwner = jest.fn<(owner: Owner, platform: string) => Promise>(); -const mockUpdateRepositoriesForIntegration = - jest.fn<(integrationId: string, repositories: unknown[]) => Promise>(); +const mockUpdateRepositoriesForIntegration = jest.fn(); const mockFetchGitLabProjects = jest.fn<(accessToken: string, instanceUrl: string) => Promise>(); +const mockListGitLabBranches = jest.fn(); +const mockFetchGitLabBranches = jest.fn(); +const mockIntegrationRows = jest.fn<() => Promise>(); +const mockFetchGitLabCredential = jest.fn(); + +jest.mock('@/lib/drizzle', () => ({ + db: { + select: () => ({ from: () => ({ where: () => ({ limit: mockIntegrationRows }) }) }), + }, +})); +jest.mock('@/lib/agent-config/db/agent-configs', () => ({})); +jest.mock('@/lib/integrations/platforms/gitlab/credential-encryption', () => ({})); +jest.mock('@/lib/integrations/platforms/gitlab/credential-broker-client', () => ({ + fetchGitLabCredential: mockFetchGitLabCredential, +})); // Wire up the mocks jest.mock('@/lib/integrations/gitlab-service', () => ({ getGitLabIntegration: mockGetGitLabIntegration, getValidGitLabToken: mockGetValidGitLabToken, + listGitLabBranches: mockListGitLabBranches, })); jest.mock('@/lib/integrations/db/platform-integrations', () => ({ @@ -35,12 +60,19 @@ jest.mock('@/lib/integrations/db/platform-integrations', () => ({ jest.mock('@/lib/integrations/platforms/gitlab/adapter', () => ({ fetchGitLabProjects: mockFetchGitLabProjects, + fetchGitLabBranches: mockFetchGitLabBranches, })); +jest.mock('@/lib/utils.server', () => ({ logExceptInTest: jest.fn() })); describe('gitlab-integration-helpers', () => { beforeEach(() => { - jest.clearAllMocks(); + jest.resetAllMocks(); jest.resetModules(); + mockGetGitLabIntegration.mockImplementation(owner => + owner.type === 'org' + ? mockGetIntegrationForOrganization(owner.id, 'gitlab') + : mockGetIntegrationForOwner(owner, 'gitlab') + ); }); describe('buildGitLabCloneUrl', () => { @@ -98,7 +130,10 @@ describe('gitlab-integration-helpers', () => { const result = await getGitLabInstanceUrlForUser('user-123'); expect(result).toBe('https://gitlab.com'); - expect(mockGetGitLabIntegration).toHaveBeenCalledWith({ type: 'user', id: 'user-123' }); + expect(mockGetGitLabIntegration).toHaveBeenCalledWith( + { type: 'user', id: 'user-123' }, + undefined + ); }); it('should return default URL when integration has no custom instance URL', async () => { @@ -488,7 +523,12 @@ describe('gitlab-integration-helpers', () => { expect(result.integrationInstalled).toBe(true); expect(result.repositories).toEqual([ - { id: 1, name: 'project', fullName: 'group/project', private: false }, + expect.objectContaining({ + id: 1, + name: 'project', + fullName: 'group/project', + private: false, + }), ]); expect(mockFetchGitLabProjects).not.toHaveBeenCalled(); }); @@ -546,7 +586,12 @@ describe('gitlab-integration-helpers', () => { expect(result.integrationInstalled).toBe(true); expect(result.repositories).toEqual([ - { id: 1, name: 'project', fullName: 'org/project', private: false }, + expect.objectContaining({ + id: 1, + name: 'project', + fullName: 'org/project', + private: false, + }), ]); expect(mockFetchGitLabProjects).not.toHaveBeenCalled(); }); @@ -582,4 +627,319 @@ describe('gitlab-integration-helpers', () => { expect(mockUpdateRepositoriesForIntegration).not.toHaveBeenCalled(); }); }); + + describe('branch identity through the GitLab service', () => { + const owner: Owner = { type: 'user', id: 'oauth/user' }; + const instanceUrl = 'https://gitlab.example.com/Enterprise'; + const projectPath = 'Group/Subgroup/API'; + const integration = { + id: 'integration-1', + platform: 'gitlab', + integration_status: 'active', + suspended_at: null, + auth_invalid_at: null, + metadata: { gitlab_instance_url: instanceUrl }, + repositories: [{ id: 42, name: 'API', full_name: projectPath, private: true }], + } as PlatformIntegration; + const reference = { + repository: { + provider: 'gitlab' as const, + instanceUrl, + repositoryId: '42', + fullName: projectPath, + defaultBranch: null, + }, + authorization: { kind: 'ownerIntegration' as const, owner, integrationId: integration.id }, + }; + + beforeEach(() => { + const service = jest.requireActual('@/lib/integrations/gitlab-service'); + mockGetGitLabIntegration.mockImplementation(service.getGitLabIntegration); + mockListGitLabBranches.mockImplementation(service.listGitLabBranches); + mockIntegrationRows.mockResolvedValue([integration]); + mockFetchGitLabCredential.mockResolvedValue({ + status: 'available', + token: 'test-token', + instanceUrl, + glabIsOAuth2: true, + }); + mockFetchGitLabBranches.mockImplementation(async (_token, projectId, host) => { + if (host !== instanceUrl) throw new Error('Wrong GitLab host'); + if (projectId === '42') return [{ name: 'release/Case', default: true, protected: true }]; + if (projectId === projectPath) + return [{ name: 'legacy-path', default: true, protected: false }]; + throw new Error('Wrong GitLab project'); + }); + }); + + it.each([owner, { type: 'org', id: 'organization-1' }])( + 'uses the immutable project ID and configured host for $type branches', + async selectedOwner => { + const { listGitLabRepositoryBranches } = await import('./gitlab-integration-helpers'); + await expect( + listGitLabRepositoryBranches(selectedOwner, 'actor', { + ...reference, + authorization: { ...reference.authorization, owner: selectedOwner }, + }) + ).resolves.toEqual({ + branches: [{ name: 'release/Case', isDefault: true }], + defaultBranch: 'release/Case', + nextCursor: null, + }); + } + ); + + it.each(['owner', 'integration', 'instance', 'repository', 'path'] as const)( + 'rejects a changed %s without substituting a project', + async changed => { + if (changed === 'integration') mockIntegrationRows.mockResolvedValue([]); + const { listGitLabRepositoryBranches } = await import('./gitlab-integration-helpers'); + await expect( + listGitLabRepositoryBranches(owner, 'actor', { + repository: { + ...reference.repository, + ...(changed === 'instance' ? { instanceUrl: 'https://other.example.com' } : {}), + ...(changed === 'repository' ? { repositoryId: '99' } : {}), + ...(changed === 'path' ? { fullName: projectPath.toLowerCase() } : {}), + }, + authorization: { + ...reference.authorization, + owner: changed === 'owner' ? { type: 'org', id: owner.id } : owner, + }, + }) + ).rejects.toMatchObject({ + code: + changed === 'owner' + ? 'FORBIDDEN' + : changed === 'instance' + ? 'PRECONDITION_FAILED' + : 'NOT_FOUND', + }); + } + ); + + it('returns zero branches without guessing a default', async () => { + mockFetchGitLabBranches.mockResolvedValue([]); + const { listGitLabRepositoryBranches } = await import('./gitlab-integration-helpers'); + await expect(listGitLabRepositoryBranches(owner, 'actor', reference)).resolves.toEqual({ + branches: [], + defaultBranch: null, + nextCursor: null, + }); + }); + + it('keeps the default unavailable when no branch is marked default', async () => { + mockFetchGitLabBranches.mockResolvedValue([ + { name: 'feature/Case', default: false, protected: false }, + ]); + const { listGitLabRepositoryBranches } = await import('./gitlab-integration-helpers'); + await expect(listGitLabRepositoryBranches(owner, 'actor', reference)).resolves.toEqual({ + branches: [{ name: 'feature/Case', isDefault: false }], + defaultBranch: null, + nextCursor: null, + }); + }); + + it('preserves a retryable provider branch failure', async () => { + const error = new Error('GitLab branch page unavailable'); + mockFetchGitLabBranches.mockRejectedValue(error); + const { listGitLabRepositoryBranches } = await import('./gitlab-integration-helpers'); + await expect(listGitLabRepositoryBranches(owner, 'actor', reference)).rejects.toBe(error); + }); + + it('retains path-only branch lookup for a legacy caller', async () => { + await expect( + mockListGitLabBranches(owner, integration.id, { userId: owner.id }, projectPath) + ).resolves.toEqual({ branches: [{ name: 'legacy-path', isDefault: true }] }); + }); + + it('rejects an ambiguous unpinned clone host', async () => { + mockIntegrationRows.mockResolvedValue([integration, { ...integration, id: 'integration-2' }]); + const { getGitLabInstanceUrlForUser } = await import('./gitlab-integration-helpers'); + await expect(getGitLabInstanceUrlForUser(owner.id)).rejects.toMatchObject({ + code: 'CONFLICT', + }); + }); + }); + + describe('discovery identity', () => { + const integrationId = '11111111-1111-4111-8111-111111111111'; + const instanceUrl = 'https://gitlab.example.com/Enterprise'; + const project = { + id: 42, + name: 'API', + full_name: 'Group/Subgroup/API', + private: true, + default_branch: 'release/Case', + }; + const owners = [ + { type: 'user', id: 'oauth/user' }, + { type: 'org', id: '22222222-2222-4222-8222-222222222222' }, + ] as const; + + it.each(owners.flatMap(owner => [false, true].map(fresh => ({ owner, fresh }))))( + 'retains the producing identity for $owner.type, fresh=$fresh', + async ({ owner, fresh }) => { + mockGetGitLabIntegration.mockResolvedValue({ + id: integrationId, + integration_status: 'active', + repositories: [project], + metadata: { gitlab_instance_url: `${instanceUrl}/` }, + repositories_synced_at: '2026-08-29T00:00:00Z', + } as PlatformIntegration); + mockGetValidGitLabToken.mockResolvedValue('token'); + mockFetchGitLabProjects.mockResolvedValue([project]); + const helpers = await import('./gitlab-integration-helpers'); + const result = + owner.type === 'user' + ? await helpers.fetchGitLabRepositoriesForUser(owner.id, fresh) + : await helpers.fetchGitLabRepositoriesForOrganization(owner.id, 'actor', fresh); + expect(result.repositories).toEqual([ + { + id: 42, + name: 'API', + fullName: 'Group/Subgroup/API', + private: true, + defaultBranch: 'release/Case', + platformIntegrationId: integrationId, + instanceUrl, + repositoryReference: { + repository: { + provider: 'gitlab', + repositoryId: '42', + instanceUrl, + fullName: 'Group/Subgroup/API', + defaultBranch: 'release/Case', + }, + authorization: { kind: 'ownerIntegration', owner, integrationId }, + }, + }, + ]); + } + ); + + it('keeps a missing old default explicitly unavailable', async () => { + mockGetGitLabIntegration.mockResolvedValue({ + id: integrationId, + repositories: [{ id: 42, name: 'API', full_name: project.full_name, private: true }], + metadata: null, + } as PlatformIntegration); + const { fetchGitLabRepositoriesForUser } = await import('./gitlab-integration-helpers'); + const result = await fetchGitLabRepositoriesForUser('oauth/user'); + expect(result.repositories[0].repositoryReference.repository).toEqual({ + provider: 'gitlab', + repositoryId: '42', + instanceUrl: 'https://gitlab.com', + fullName: project.full_name, + defaultBranch: null, + }); + expect(result.repositories[0].defaultBranch).toBeUndefined(); + }); + + it('does not relabel fresh repositories after the integration is replaced', async () => { + mockGetGitLabIntegration.mockResolvedValue({ + id: integrationId, + repositories: [], + metadata: { gitlab_instance_url: instanceUrl }, + } as unknown as PlatformIntegration); + mockGetValidGitLabToken.mockResolvedValue('token'); + mockFetchGitLabProjects.mockImplementation(async () => { + mockGetGitLabIntegration.mockResolvedValue({ + id: 'replacement', + metadata: { gitlab_instance_url: 'https://other.example.com' }, + } as PlatformIntegration); + return [project]; + }); + const { fetchGitLabRepositoriesForUser } = await import('./gitlab-integration-helpers'); + const result = await fetchGitLabRepositoriesForUser('oauth/user', true); + expect(result.repositories[0].repositoryReference).toMatchObject({ + repository: { instanceUrl, repositoryId: '42' }, + authorization: { integrationId }, + }); + }); + + it.each(owners)( + 'keeps same-name projects on the selected $type integration host', + async owner => { + mockGetGitLabIntegration.mockImplementation( + async (_owner, selector) => + ({ + id: selector, + repositories: [project], + metadata: { + gitlab_instance_url: + selector === integrationId ? instanceUrl : 'https://other.example.com/gitlab', + }, + }) as PlatformIntegration + ); + const helpers = await import('./gitlab-integration-helpers'); + const getHost = + owner.type === 'user' + ? helpers.getGitLabInstanceUrlForUser + : helpers.getGitLabInstanceUrlForOrganization; + expect( + helpers.buildGitLabCloneUrl(project.full_name, await getHost(owner.id, integrationId)) + ).toBe('https://gitlab.example.com/Enterprise/Group/Subgroup/API.git'); + expect( + helpers.buildGitLabCloneUrl(project.full_name, await getHost(owner.id, 'other')) + ).toBe('https://other.example.com/gitlab/Group/Subgroup/API.git'); + } + ); + + it.each(owners)('rejects a stale $type selector instead of using gitlab.com', async owner => { + mockGetGitLabIntegration.mockResolvedValue(null); + const helpers = await import('./gitlab-integration-helpers'); + const getHost = + owner.type === 'user' + ? helpers.getGitLabInstanceUrlForUser + : helpers.getGitLabInstanceUrlForOrganization; + await expect(getHost(owner.id, integrationId)).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('rejects a changed configured host on an existing integration', async () => { + mockGetGitLabIntegration.mockResolvedValue({ + id: integrationId, + metadata: { gitlab_instance_url: 'https://other.example.com' }, + } as PlatformIntegration); + const { getGitLabInstanceUrlForUser } = await import('./gitlab-integration-helpers'); + await expect( + getGitLabInstanceUrlForUser('oauth/user', integrationId, instanceUrl) + ).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }); + }); + + it('preserves the old discovery error when a provider page fails', async () => { + mockGetGitLabIntegration.mockResolvedValue({ + id: integrationId, + repositories: null, + metadata: {}, + } as PlatformIntegration); + mockGetValidGitLabToken.mockResolvedValue('token'); + mockFetchGitLabProjects.mockRejectedValue(new Error('page 2 failed')); + const { fetchGitLabRepositoriesForUser } = await import('./gitlab-integration-helpers'); + await expect(fetchGitLabRepositoriesForUser('oauth/user', true)).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to fetch GitLab repositories', + }); + }); + + it('distinguishes a connected empty repository list from no integration', async () => { + mockGetGitLabIntegration.mockResolvedValue({ + id: integrationId, + repositories: null, + metadata: {}, + } as PlatformIntegration); + mockGetValidGitLabToken.mockResolvedValue('token'); + mockFetchGitLabProjects.mockResolvedValue([]); + const { fetchGitLabRepositoriesForUser } = await import('./gitlab-integration-helpers'); + await expect(fetchGitLabRepositoriesForUser('oauth/user')).resolves.toMatchObject({ + integrationInstalled: true, + repositories: [], + }); + mockGetGitLabIntegration.mockResolvedValue(null); + await expect(fetchGitLabRepositoriesForUser('oauth/user')).resolves.toMatchObject({ + integrationInstalled: false, + repositories: [], + }); + }); + }); }); diff --git a/apps/web/src/lib/cloud-agent/gitlab-integration-helpers.ts b/apps/web/src/lib/cloud-agent/gitlab-integration-helpers.ts index 510c91b50d..5cc58795f3 100644 --- a/apps/web/src/lib/cloud-agent/gitlab-integration-helpers.ts +++ b/apps/web/src/lib/cloud-agent/gitlab-integration-helpers.ts @@ -1,10 +1,13 @@ import { TRPCError } from '@trpc/server'; import { getIntegrationForOrganization, - getIntegrationForOwner, updateRepositoriesForIntegration, } from '@/lib/integrations/db/platform-integrations'; -import { getGitLabIntegration, getValidGitLabToken } from '@/lib/integrations/gitlab-service'; +import { + getGitLabIntegration, + getValidGitLabToken, + listGitLabBranches, +} from '@/lib/integrations/gitlab-service'; import { fetchGitLabProjects } from '@/lib/integrations/platforms/gitlab/adapter'; import { PLATFORM } from '@/lib/integrations/core/constants'; import { isPlatformIntegrationSuspended } from '@/lib/integrations/core/health'; @@ -12,6 +15,11 @@ import { requireNumericPlatformRepositories, type PlatformRepository, } from '@/lib/integrations/core/types'; +import type { + LaunchRepositoryReference, + Owner, +} from '@kilocode/app-shared/code-review/repository-identity'; +import { normalizeGitLabInstanceUrl } from '@/lib/integrations/platforms/gitlab/instance-url'; const DEFAULT_GITLAB_URL = 'https://gitlab.com'; @@ -22,6 +30,10 @@ type GitLabRepositoriesResult = { name: string; fullName: string; private: boolean; + defaultBranch?: string; + platformIntegrationId: string; + instanceUrl: string; + repositoryReference: LaunchRepositoryReference; }[]; syncedAt?: string | null; errorMessage?: string; @@ -29,13 +41,31 @@ type GitLabRepositoriesResult = { }; const mapRepositories = ( - repositories: PlatformRepository[] + repositories: PlatformRepository[], + integrationId: string, + instanceUrl: string, + owner: Owner ): GitLabRepositoriesResult['repositories'] => { return repositories.map(repo => ({ id: repo.id, name: repo.name, fullName: repo.full_name, private: repo.private, + defaultBranch: repo.default_branch, + platformIntegrationId: integrationId, + instanceUrl, + repositoryReference: { + repository: { + provider: 'gitlab', + instanceUrl, + repositoryId: String(repo.id), + fullName: repo.full_name, + // Old cache rows omit defaults. Remove this unavailable fallback only after + // old rows/clients disappear and the 30-day ledger window expires. + defaultBranch: repo.default_branch ?? null, + }, + authorization: { kind: 'ownerIntegration', owner, integrationId }, + }, })); }; @@ -47,35 +77,19 @@ const missingIntegrationResponse = (message: string): GitLabRepositoriesResult = }); type GitLabMetadata = { - access_token?: string; - refresh_token?: string; - token_expires_at?: string; gitlab_instance_url?: string; - client_id?: string; - client_secret?: string; }; -/** - * Get GitLab OAuth token for an organization - * Automatically refreshes the token if expired - */ +/** Get the organization's GitLab token through the credential broker. */ export async function getGitLabTokenForOrganization( organizationId: string, actorUserId: string ): Promise { const integration = await getIntegrationForOrganization(organizationId, PLATFORM.GITLAB); - - if (!integration) { - return undefined; - } - + if (!integration) return undefined; try { - const token = await getValidGitLabToken(integration, { - userId: actorUserId, - organizationId, - }); - return token; - } catch (_error) { + return await getValidGitLabToken(integration, { userId: actorUserId, organizationId }); + } catch { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to authenticate with GitLab integration', @@ -83,21 +97,13 @@ export async function getGitLabTokenForOrganization( } } -/** - * Get GitLab OAuth token for a user - * Automatically refreshes the token if expired - */ +/** Get the user's GitLab token through the credential broker. */ export async function getGitLabTokenForUser(userId: string): Promise { const integration = await getGitLabIntegration({ type: 'user', id: userId }); - - if (!integration) { - return undefined; - } - + if (!integration) return undefined; try { - const token = await getValidGitLabToken(integration, { userId }); - return token; - } catch (_error) { + return await getValidGitLabToken(integration, { userId }); + } catch { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to authenticate with GitLab integration', @@ -105,54 +111,47 @@ export async function getGitLabTokenForUser(userId: string): Promise { - const integration = await getIntegrationForOrganization(organizationId, PLATFORM.GITLAB); - + const integration = await getGitLabIntegration(owner, integrationId); if (!integration) { - return missingIntegrationResponse('No GitLab integration found for this organization'); + return missingIntegrationResponse( + `No GitLab integration found for this ${owner.type === 'org' ? 'organization' : 'user'}` + ); } - if (isPlatformIntegrationSuspended(integration)) { return missingIntegrationResponse('GitLab integration is suspended'); } - const metadata = integration.metadata as GitLabMetadata | null; - const instanceUrl = metadata?.gitlab_instance_url || DEFAULT_GITLAB_URL; - try { + const metadata = integration.metadata as GitLabMetadata | null; + const instanceUrl = normalizeGitLabInstanceUrl(metadata?.gitlab_instance_url); const cachedRepositories = requireNumericPlatformRepositories(integration.repositories); - // If forceRefresh or no cached repos, fetch from GitLab and update cache if (forceRefresh || !cachedRepositories?.length) { const accessToken = await getValidGitLabToken(integration, { userId: actorUserId, - organizationId, + ...(owner.type === 'org' ? { organizationId: owner.id } : {}), }); const repositories = await fetchGitLabProjects(accessToken, instanceUrl); - await updateRepositoriesForIntegration(integration.id, repositories); + await updateRepositoriesForIntegration(integration.id, repositories, integration); return { integrationInstalled: true, - repositories: mapRepositories(repositories), + repositories: mapRepositories(repositories, integration.id, instanceUrl, owner), syncedAt: new Date().toISOString(), instanceUrl, }; } - - // Return cached repos return { integrationInstalled: true, - repositories: mapRepositories(cachedRepositories), + repositories: mapRepositories(cachedRepositories, integration.id, instanceUrl, owner), syncedAt: integration.repositories_synced_at, instanceUrl, }; - } catch (_error) { + } catch { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to fetch GitLab repositories', @@ -160,77 +159,39 @@ export async function fetchGitLabRepositoriesForOrganization( } } -/** - * Fetch GitLab repositories for a user - * Returns cached repositories by default, fetches fresh from GitLab when forceRefresh is true - */ -export async function fetchGitLabRepositoriesForUser( - userId: string, - forceRefresh: boolean = false +export function fetchGitLabRepositoriesForOrganization( + organizationId: string, + actorUserId: string, + forceRefresh: boolean = false, + integrationId?: string ): Promise { - const integration = await getIntegrationForOwner({ type: 'user', id: userId }, PLATFORM.GITLAB); - - if (!integration) { - return missingIntegrationResponse('No GitLab integration found for this user'); - } - - if (isPlatformIntegrationSuspended(integration)) { - return missingIntegrationResponse('GitLab integration is suspended'); - } - - const metadata = integration.metadata as GitLabMetadata | null; - const instanceUrl = metadata?.gitlab_instance_url || DEFAULT_GITLAB_URL; - - try { - const cachedRepositories = requireNumericPlatformRepositories(integration.repositories); - // If forceRefresh or no cached repos, fetch from GitLab and update cache - if (forceRefresh || !cachedRepositories?.length) { - const accessToken = await getValidGitLabToken(integration, { userId }); - const repositories = await fetchGitLabProjects(accessToken, instanceUrl); - await updateRepositoriesForIntegration(integration.id, repositories); - return { - integrationInstalled: true, - repositories: mapRepositories(repositories), - syncedAt: new Date().toISOString(), - instanceUrl, - }; - } + return fetchRepositories( + { type: 'org', id: organizationId }, + actorUserId, + forceRefresh, + integrationId + ); +} - // Return cached repos - return { - integrationInstalled: true, - repositories: mapRepositories(cachedRepositories), - syncedAt: integration.repositories_synced_at, - instanceUrl, - }; - } catch (_error) { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Failed to fetch GitLab repositories', - }); - } +export function fetchGitLabRepositoriesForUser( + userId: string, + forceRefresh: boolean = false, + integrationId?: string +): Promise { + return fetchRepositories({ type: 'user', id: userId }, userId, forceRefresh, integrationId); } -/** - * Validate that a user has access to a specific GitLab project - * @param userId - The user ID - * @param projectPath - GitLab project path (e.g., "group/project" or "group/subgroup/project") - */ export async function validateGitLabRepoAccessForUser( userId: string, projectPath: string ): Promise { try { const result = await fetchGitLabRepositoriesForUser(userId, false); - - if (!result.integrationInstalled || !result.repositories.length) { - return false; - } - - return result.repositories.some( - repo => repo.fullName.toLowerCase() === projectPath.toLowerCase() + return ( + result.integrationInstalled && + result.repositories.some(repo => repo.fullName.toLowerCase() === projectPath.toLowerCase()) ); - } catch (_error) { + } catch { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to validate GitLab repository access', @@ -238,11 +199,6 @@ export async function validateGitLabRepoAccessForUser( } } -/** - * Validate that an organization has access to a specific GitLab project - * @param organizationId - The organization ID - * @param projectPath - GitLab project path (e.g., "group/project" or "group/subgroup/project") - */ export async function validateGitLabRepoAccessForOrganization( organizationId: string, actorUserId: string, @@ -250,15 +206,11 @@ export async function validateGitLabRepoAccessForOrganization( ): Promise { try { const result = await fetchGitLabRepositoriesForOrganization(organizationId, actorUserId, false); - - if (!result.integrationInstalled || !result.repositories.length) { - return false; - } - - return result.repositories.some( - repo => repo.fullName.toLowerCase() === projectPath.toLowerCase() + return ( + result.integrationInstalled && + result.repositories.some(repo => repo.fullName.toLowerCase() === projectPath.toLowerCase()) ); - } catch (_error) { + } catch { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to validate GitLab repository access', @@ -266,51 +218,81 @@ export async function validateGitLabRepoAccessForOrganization( } } -/** - * Build a GitLab clone URL from a project path - * @param projectPath - GitLab project path (e.g., "group/project" or "group/subgroup/project") - * @param instanceUrl - GitLab instance URL (defaults to https://gitlab.com) - * @returns HTTPS clone URL for the project - */ export function buildGitLabCloneUrl( projectPath: string, instanceUrl: string = DEFAULT_GITLAB_URL ): string { - // Ensure instanceUrl doesn't have a trailing slash const baseUrl = instanceUrl.replace(/\/$/, ''); - // Ensure projectPath doesn't have leading/trailing slashes const cleanPath = projectPath.replace(/^\/|\/$/g, ''); return `${baseUrl}/${cleanPath}.git`; } -/** - * Get the GitLab instance URL for a user's integration - * @param userId - The user ID - * @returns The GitLab instance URL or default gitlab.com - */ -export async function getGitLabInstanceUrlForUser(userId: string): Promise { - const integration = await getGitLabIntegration({ type: 'user', id: userId }); - - if (!integration) { - return DEFAULT_GITLAB_URL; +async function getInstanceUrl(owner: Owner, integrationId?: string, expectedInstanceUrl?: string) { + const integration = await getGitLabIntegration(owner, integrationId); + if (!integration && (integrationId || expectedInstanceUrl)) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'GitLab integration not found' }); + } + // Old unpinned callers retain downstream authorization errors. Remove this + // fallback only after old clients/records and the 30-day ledger window expire. + if ( + (integrationId || expectedInstanceUrl) && + integration && + isPlatformIntegrationSuspended(integration) + ) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'GitLab integration is suspended', + }); } + const metadata = integration?.metadata as GitLabMetadata | null; + // Old callers omit selectors and old metadata omits the host. The unambiguous + // lookup retains gitlab.com until old clients/records and the 30-day window expire. + const instanceUrl = normalizeGitLabInstanceUrl(metadata?.gitlab_instance_url); + if (expectedInstanceUrl && normalizeGitLabInstanceUrl(expectedInstanceUrl) !== instanceUrl) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'GitLab integration changed' }); + } + return instanceUrl; +} - const metadata = integration.metadata as GitLabMetadata | null; - return metadata?.gitlab_instance_url || DEFAULT_GITLAB_URL; +export function getGitLabInstanceUrlForUser( + userId: string, + integrationId?: string, + expectedInstanceUrl?: string +): Promise { + return getInstanceUrl({ type: 'user', id: userId }, integrationId, expectedInstanceUrl); } -/** - * Get the GitLab instance URL for an organization's integration - * @param organizationId - The organization ID - * @returns The GitLab instance URL or default gitlab.com - */ -export async function getGitLabInstanceUrlForOrganization(organizationId: string): Promise { - const integration = await getIntegrationForOrganization(organizationId, PLATFORM.GITLAB); +export function getGitLabInstanceUrlForOrganization( + organizationId: string, + integrationId?: string, + expectedInstanceUrl?: string +): Promise { + return getInstanceUrl({ type: 'org', id: organizationId }, integrationId, expectedInstanceUrl); +} - if (!integration) { - return DEFAULT_GITLAB_URL; +export async function listGitLabRepositoryBranches( + owner: Owner, + actorUserId: string, + reference: LaunchRepositoryReference +) { + const { repository, authorization } = reference; + if ( + repository.provider !== 'gitlab' || + authorization.owner.type !== owner.type || + authorization.owner.id !== owner.id + ) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Repository owner does not match' }); } - - const metadata = integration.metadata as GitLabMetadata | null; - return metadata?.gitlab_instance_url || DEFAULT_GITLAB_URL; + const { branches } = await listGitLabBranches( + owner, + authorization.integrationId, + { userId: actorUserId, ...(owner.type === 'org' ? { organizationId: owner.id } : {}) }, + repository.fullName, + repository + ); + return { + branches, + defaultBranch: branches.find(branch => branch.isDefault)?.name ?? null, + nextCursor: null, + }; } 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 41b16a3a0e..f53aa88b1f 100644 --- a/apps/web/src/lib/integrations/db/platform-integrations.test.ts +++ b/apps/web/src/lib/integrations/db/platform-integrations.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from '@jest/globals'; +import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals'; import { db } from '@/lib/drizzle'; import { platform_integrations, kilocode_users, organizations } from '@kilocode/db/schema'; import { and, eq } from 'drizzle-orm'; @@ -12,9 +12,29 @@ import { unsuspendIntegration, unsuspendIntegrationForOwner, updateIntegrationRepositories, + updateRepositoriesForIntegration, upsertPlatformIntegrationForOwner, } from './platform-integrations'; import type { Owner } from '../core/types'; +import type * as GitLabAdapter from '../platforms/gitlab/adapter'; +import type { fetchGitLabCredential } from '../platforms/gitlab/credential-broker-client'; + +const mockFetchGitLabProjects = jest.fn(); +const mockValidatePersonalAccessToken = jest.fn(); +const mockFetchGitLabCredential = jest.fn(); +jest.mock('@/lib/integrations/platforms/gitlab/adapter', () => ({ + fetchGitLabProjects: mockFetchGitLabProjects, + validatePersonalAccessToken: mockValidatePersonalAccessToken, +})); +jest.mock('@/lib/integrations/platforms/gitlab/credential-broker-client', () => ({ + fetchGitLabCredential: mockFetchGitLabCredential, +})); +jest.mock('@/lib/integrations/platforms/gitlab/credential-encryption', () => ({ + encryptGitLabPersonalAccessToken: () => 'test-encrypted-token', +})); +jest.mock('@/lib/agent-config/db/agent-configs', () => ({ + resetCodeReviewConfigForOwner: jest.fn(), +})); const INSTALLATION_ID = `test-github-install-${Date.now()}`; @@ -356,6 +376,249 @@ describe('upsertPlatformIntegrationForOwner', () => { expect(lite?.github_app_type).toBe('lite'); }); + describe('GitLab repository cache snapshots', () => { + const instanceUrl = 'https://gitlab.example.com/Enterprise'; + const replacementUrl = 'https://other.example.com/gitlab'; + const project = { + id: 42, + name: 'API', + full_name: 'Group/Subgroup/API', + private: true, + created_at: '2026-08-01T00:00:00Z', + default_branch: 'release/Original', + }; + const replacementProject = { ...project, default_branch: 'release/Replacement' }; + + async function createIntegration( + owner: Owner, + metadata: unknown = { gitlab_instance_url: instanceUrl } + ) { + const [integration] = await db + .insert(platform_integrations) + .values({ + owned_by_user_id: owner.type === 'user' ? owner.id : null, + owned_by_organization_id: owner.type === 'org' ? owner.id : null, + platform: 'gitlab', + integration_type: 'oauth', + integration_status: 'active', + repositories: [project], + repositories_synced_at: '2026-08-29T08:00:00Z', + metadata, + updated_at: '2026-08-29 08:00:00.123456+00', + }) + .returning(); + return integration; + } + + beforeEach(() => { + mockFetchGitLabProjects.mockReset(); + mockValidatePersonalAccessToken.mockReset(); + mockValidatePersonalAccessToken.mockResolvedValue({ + valid: true, + user: { + id: 123, + username: 'gitlab-user', + name: 'GitLab User', + email: 'gitlab-user@example.com', + avatar_url: 'https://gitlab.com/avatar.png', + web_url: 'https://gitlab.com/gitlab-user', + }, + }); + mockFetchGitLabCredential.mockReset(); + mockFetchGitLabCredential.mockResolvedValue({ + status: 'available', + token: 'test-gitlab-token', + instanceUrl, + glabIsOAuth2: true, + }); + }); + + test.each([null, { gitlab_instance_url: instanceUrl }])( + 'updates a matching snapshot with metadata %j', + async metadata => { + const integration = await createIntegration({ type: 'user', id: userId }, metadata); + await updateRepositoriesForIntegration(integration.id, [replacementProject], integration); + const [current] = await db + .select() + .from(platform_integrations) + .where(eq(platform_integrations.id, integration.id)); + expect(current.repositories).toEqual([replacementProject]); + } + ); + + test.each(['timestamp', 'metadata'] as const)( + 'does not overwrite the cache or authorization after a changed %s', + async changed => { + const integration = await createIntegration({ type: 'user', id: userId }); + const [replacement] = await db + .update(platform_integrations) + .set({ + repositories: [replacementProject], + repositories_synced_at: '2026-08-29T09:00:00Z', + auth_invalid_at: '2026-08-29T09:00:00Z', + auth_invalid_reason: 'reconnect_required', + metadata: + changed === 'metadata' + ? { gitlab_instance_url: replacementUrl } + : integration.metadata, + updated_at: changed === 'timestamp' ? '2026-08-29T09:00:00Z' : integration.updated_at, + }) + .where(eq(platform_integrations.id, integration.id)) + .returning(); + await updateRepositoriesForIntegration(integration.id, [project], integration); + const [current] = await db + .select() + .from(platform_integrations) + .where(eq(platform_integrations.id, integration.id)); + expect(current).toEqual(replacement); + } + ); + + test('preserves the old two-argument cache writer', async () => { + const integration = await createIntegration({ type: 'user', id: userId }); + await updateRepositoriesForIntegration(integration.id, [replacementProject]); + const [current] = await db + .select() + .from(platform_integrations) + .where(eq(platform_integrations.id, integration.id)); + expect(current.repositories).toEqual([replacementProject]); + }); + + test.each(['personal', 'organization', 'service'] as const)( + 'keeps the reconnected host cache after a late %s refresh', + async context => { + const owner: Owner = + context === 'organization' ? { type: 'org', id: orgId } : { type: 'user', id: userId }; + const integration = await createIntegration(owner); + mockFetchGitLabProjects.mockImplementationOnce(async () => { + await db + .update(platform_integrations) + .set({ + metadata: { gitlab_instance_url: replacementUrl }, + repositories: [replacementProject], + updated_at: '2026-08-29T09:00:00Z', + }) + .where(eq(platform_integrations.id, integration.id)); + return [project]; + }); + const helpers = await import('@/lib/cloud-agent/gitlab-integration-helpers'); + const { listGitLabRepositories } = await import('../gitlab-service'); + const fresh = + context === 'service' + ? await listGitLabRepositories(owner, integration.id, { userId }, true) + : owner.type === 'user' + ? await helpers.fetchGitLabRepositoriesForUser(owner.id, true, integration.id) + : await helpers.fetchGitLabRepositoriesForOrganization( + owner.id, + userId, + true, + integration.id + ); + if ('instanceUrl' in fresh) { + expect(fresh.instanceUrl).toBe(instanceUrl); + expect(fresh.repositories[0]).toMatchObject({ + repositoryReference: { + repository: { instanceUrl, repositoryId: '42', defaultBranch: 'release/Original' }, + authorization: { kind: 'ownerIntegration', owner, integrationId: integration.id }, + }, + }); + } else { + expect(fresh.repositories).toEqual([project]); + } + const cached = + owner.type === 'user' + ? await helpers.fetchGitLabRepositoriesForUser(owner.id, false, integration.id) + : await helpers.fetchGitLabRepositoriesForOrganization( + owner.id, + userId, + false, + integration.id + ); + expect(cached.repositories[0]).toMatchObject({ + fullName: 'Group/Subgroup/API', + defaultBranch: 'release/Replacement', + repositoryReference: { + repository: { + instanceUrl: replacementUrl, + repositoryId: '42', + defaultBranch: 'release/Replacement', + }, + authorization: { kind: 'ownerIntegration', owner, integrationId: integration.id }, + }, + }); + } + ); + + test('clears the old host cache before fetching projects during PAT reconnect', async () => { + const owner: Owner = { type: 'user', id: userId }; + const integration = await createIntegration(owner); + const service = await import('../gitlab-service'); + mockFetchGitLabProjects.mockImplementationOnce(async () => { + const current = await service.getGitLabIntegration(owner, integration.id); + expect(current?.metadata).toMatchObject({ gitlab_instance_url: replacementUrl }); + expect(current?.repositories).toBeNull(); + expect(current?.repositories_synced_at).toBeNull(); + return [replacementProject]; + }); + await expect( + service.connectWithPAT(owner, 'test-pat', replacementUrl, userId) + ).resolves.toMatchObject({ + success: true, + integration: { id: integration.id, instanceUrl: replacementUrl }, + }); + const current = await service.getGitLabIntegration(owner, integration.id); + expect(current?.repositories).toEqual([replacementProject]); + }); + + test.each(['existing', 'new'] as const)( + 'does not replace a reconnected cache after an %s PAT connection fetch', + async kind => { + const owner: Owner = { type: 'user', id: userId }; + if (kind === 'existing') await createIntegration(owner); + const service = await import('../gitlab-service'); + mockFetchGitLabProjects.mockImplementationOnce(async () => { + const current = await service.getGitLabIntegration(owner); + if (!current) throw new Error('Missing test integration'); + await db + .update(platform_integrations) + .set({ + metadata: { gitlab_instance_url: replacementUrl }, + repositories: [replacementProject], + updated_at: '2026-08-29T09:00:00Z', + }) + .where(eq(platform_integrations.id, current.id)); + return [project]; + }); + await service.connectWithPAT(owner, 'test-pat', instanceUrl, userId); + const current = await service.getGitLabIntegration(owner); + expect(current?.metadata).toEqual({ gitlab_instance_url: replacementUrl }); + expect(current?.repositories).toEqual([replacementProject]); + } + ); + + test.each(['user', 'org'] as const)( + 'rejects ambiguous legacy %s lookups but accepts exact selectors', + async type => { + const owner: Owner = type === 'user' ? { type, id: userId } : { type, id: orgId }; + const integration = await createIntegration(owner); + const { getGitLabIntegration } = await import('../gitlab-service'); + expect((await getGitLabIntegration(owner))?.id).toBe(integration.id); + const second = await createIntegration(owner, { gitlab_instance_url: replacementUrl }); + await expect(getGitLabIntegration(owner)).rejects.toMatchObject({ code: 'CONFLICT' }); + expect((await getGitLabIntegration(owner, integration.id))?.metadata).toEqual({ + gitlab_instance_url: instanceUrl, + }); + expect((await getGitLabIntegration(owner, second.id))?.metadata).toEqual({ + gitlab_instance_url: replacementUrl, + }); + const otherOwner: Owner = + type === 'user' ? { type, id: otherUserId } : { type, id: otherOrgId }; + await expect(getGitLabIntegration(otherOwner, integration.id)).resolves.toBeNull(); + await expect(getGitLabIntegration(owner, crypto.randomUUID())).resolves.toBeNull(); + } + ); + }); + describe('app-type-scoped destructive mutations', () => { const destructiveInstallId = `test-github-destructive-${Date.now()}`; const siblingInstallId = `test-github-destructive-sibling-${Date.now()}`; diff --git a/apps/web/src/lib/integrations/db/platform-integrations.ts b/apps/web/src/lib/integrations/db/platform-integrations.ts index ef483ca556..6075e3248c 100644 --- a/apps/web/src/lib/integrations/db/platform-integrations.ts +++ b/apps/web/src/lib/integrations/db/platform-integrations.ts @@ -1,5 +1,5 @@ import { db } from '@/lib/drizzle'; -import { platform_integrations } from '@kilocode/db/schema'; +import { platform_integrations, type PlatformIntegration } from '@kilocode/db/schema'; import { eq, and, isNull, asc, desc, sql } from 'drizzle-orm'; import type { GitHubRequester, @@ -228,11 +228,14 @@ export async function updateIntegrationRepositories( } /** - * Updates repository list for an integration by integration ID + * Updates the cache only if the optional integration snapshot still matches. + * Old writers omit the snapshot. Keep that form until old callers/records + * disappear and the 30-day ledger window expires. */ export async function updateRepositoriesForIntegration( integrationId: string, - repositories: PlatformRepository[] + repositories: PlatformRepository[], + expectedIntegration?: Pick ) { await db .update(platform_integrations) @@ -243,7 +246,19 @@ export async function updateRepositoriesForIntegration( auth_invalid_reason: null, updated_at: new Date().toISOString(), }) - .where(eq(platform_integrations.id, integrationId)); + .where( + and( + eq(platform_integrations.id, integrationId), + expectedIntegration + ? eq(platform_integrations.updated_at, expectedIntegration.updated_at) + : undefined, + expectedIntegration + ? expectedIntegration.metadata === null + ? isNull(platform_integrations.metadata) + : eq(platform_integrations.metadata, expectedIntegration.metadata) + : undefined + ) + ); } export async function updateIntegrationAccountIdentity( diff --git a/apps/web/src/lib/integrations/gitlab-service.ts b/apps/web/src/lib/integrations/gitlab-service.ts index 62fecb934b..91ebf7bb1e 100644 --- a/apps/web/src/lib/integrations/gitlab-service.ts +++ b/apps/web/src/lib/integrations/gitlab-service.ts @@ -138,19 +138,39 @@ function requireGitLabProjectId(projectId: string | number): string { /** * Get GitLab integration for an owner */ -export async function getGitLabIntegration(owner: Owner): Promise { +export async function getGitLabIntegration( + owner: Owner, + integrationId?: string +): Promise { const ownershipCondition = owner.type === 'user' - ? eq(platform_integrations.owned_by_user_id, owner.id) - : eq(platform_integrations.owned_by_organization_id, owner.id); + ? and( + eq(platform_integrations.owned_by_user_id, owner.id), + isNull(platform_integrations.owned_by_organization_id) + ) + : and( + eq(platform_integrations.owned_by_organization_id, owner.id), + isNull(platform_integrations.owned_by_user_id) + ); - const [integration] = await db + const integrations = await db .select() .from(platform_integrations) - .where(and(ownershipCondition, eq(platform_integrations.platform, PLATFORM.GITLAB))) - .limit(1); + .where( + and( + ownershipCondition, + eq(platform_integrations.platform, PLATFORM.GITLAB), + integrationId ? eq(platform_integrations.id, integrationId) : undefined + ) + ) + .limit(2); - return integration || null; + // Old callers omit the selector. Resolve only an unambiguous owner lookup until + // old clients/records disappear and the 30-day ledger window expires. + if (integrations.length > 1) { + throw new TRPCError({ code: 'CONFLICT', message: 'Select a GitLab integration' }); + } + return integrations[0] ?? null; } /** @@ -266,7 +286,7 @@ export async function listGitLabRepositories( const instanceUrl = normalizeInstanceUrl(metadata?.gitlab_instance_url); const repos = await fetchGitLabProjects(accessToken, instanceUrl); - await updateRepositoriesForIntegration(integrationId, repos); + await updateRepositoriesForIntegration(integrationId, repos, integration); return { repositories: repos, @@ -289,43 +309,49 @@ export async function listGitLabBranches( owner: Owner, integrationId: string, actor: GitLabCredentialActor, - projectPath: string // e.g., "group/project" or project ID + projectPath: string, + expectedRepository?: { instanceUrl: string; repositoryId: string } ) { - const ownershipCondition = - owner.type === 'user' - ? eq(platform_integrations.owned_by_user_id, owner.id) - : eq(platform_integrations.owned_by_organization_id, owner.id); - - const [integration] = await db - .select() - .from(platform_integrations) - .where( - and( - eq(platform_integrations.id, integrationId), - ownershipCondition, - eq(platform_integrations.platform, PLATFORM.GITLAB) - ) - ) - .limit(1); - + const integration = await getGitLabIntegration(owner, integrationId); if (!integration) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'GitLab integration not found', - }); + throw new TRPCError({ code: 'NOT_FOUND', message: 'GitLab integration not found' }); } - const accessToken = await getValidGitLabToken(integration, actor); const metadata = integration.metadata as { gitlab_instance_url?: string } | null; const instanceUrl = normalizeInstanceUrl(metadata?.gitlab_instance_url); - - const branches = await fetchGitLabBranches(accessToken, projectPath, instanceUrl); - + if ( + expectedRepository && + (normalizeInstanceUrl(expectedRepository.instanceUrl) !== instanceUrl || + integration.integration_status !== INTEGRATION_STATUS.ACTIVE || + integration.suspended_at || + integration.auth_invalid_at) + ) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'GitLab integration changed' }); + } + const accessToken = await getValidGitLabToken(integration, actor); + if (expectedRepository) { + const repositories = + requireNumericPlatformRepositories(integration.repositories) ?? + (await fetchGitLabProjects(accessToken, instanceUrl)); + if ( + !repositories.some( + repository => + String(repository.id) === expectedRepository.repositoryId && + repository.full_name === projectPath + ) + ) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'GitLab repository not found' }); + } + } + // Legacy branch callers supply a path only. Keep that form until old clients + // and records disappear and the 30-day ledger window expires. + const branches = await fetchGitLabBranches( + accessToken, + expectedRepository?.repositoryId ?? projectPath, + instanceUrl + ); return { - branches: branches.map(b => ({ - name: b.name, - isDefault: b.default, - })), + branches: branches.map(b => ({ name: b.name, isDefault: b.default })), }; } @@ -1011,7 +1037,7 @@ export async function connectWithPAT( let existingMetadata: Record = {}; let isInstanceChange = false; - await db.transaction(async tx => { + const refreshedIntegration = await db.transaction(async tx => { existingMetadata = await readGitLabMetadataInTransaction(tx, existingIntegration.id); isInstanceChange = instanceUrlChanged( readOptionalMetadataString(existingMetadata, 'gitlab_instance_url'), @@ -1058,7 +1084,7 @@ export async function connectWithPAT( ], }); - await tx + const [updatedIntegration] = await tx .update(platform_integrations) .set({ integration_type: 'pat', @@ -1067,9 +1093,12 @@ export async function connectWithPAT( platform_account_login: validatedUser.username, scopes: validation.tokenInfo?.scopes ?? ['api'], integration_status: INTEGRATION_STATUS.ACTIVE, + ...(isInstanceChange ? { repositories: null, repositories_synced_at: null } : {}), updated_at: new Date().toISOString(), }) - .where(eq(platform_integrations.id, existingIntegration.id)); + .where(eq(platform_integrations.id, existingIntegration.id)) + .returning(); + if (!updatedIntegration) throw new Error('GitLab integration changed during reconnect'); if (isInstanceChange) { await tx @@ -1129,6 +1158,7 @@ export async function connectWithPAT( await tx .delete(platform_oauth_credentials) .where(eq(platform_oauth_credentials.platform_integration_id, existingIntegration.id)); + return updatedIntegration; }); if (isInstanceChange) { @@ -1161,7 +1191,7 @@ export async function connectWithPAT( // Fetch and cache repositories const repos = await fetchGitLabProjects(token, normalizedInstanceUrl); - await updateRepositoriesForIntegration(existingIntegration.id, repos); + await updateRepositoriesForIntegration(existingIntegration.id, repos, refreshedIntegration); return { success: true, @@ -1253,7 +1283,7 @@ export async function connectWithPAT( // 6. Fetch and cache repositories const repos = await fetchGitLabProjects(token, normalizedInstanceUrl); - await updateRepositoriesForIntegration(integration.id, repos); + await updateRepositoriesForIntegration(integration.id, repos, integration); logExceptInTest('[connectWithPAT] Repositories cached', { integrationId: integration.id, diff --git a/apps/web/src/lib/integrations/platforms/github/adapter.test.ts b/apps/web/src/lib/integrations/platforms/github/adapter.test.ts new file mode 100644 index 0000000000..e19657daed --- /dev/null +++ b/apps/web/src/lib/integrations/platforms/github/adapter.test.ts @@ -0,0 +1,164 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import type { PlatformIntegration } from '@kilocode/db/schema'; +import type { Owner } from '@/lib/integrations/core/types'; +import type * as Adapter from './adapter'; +import type * as Helpers from '@/lib/cloud-agent/github-integration-helpers'; + +const repository = { + id: 42, + name: 'API', + full_name: 'acme/API', + private: true, + archived: false, + created_at: '2026-08-01T00:00:00Z', + default_branch: 'release/Case', +}; +type ProviderRepository = Omit & { + default_branch?: string | null; +}; +const mockListRepositories = + jest.fn<() => Promise<{ data: { repositories: ProviderRepository[] } }>>(); +const mockGetRepository = jest.fn<() => Promise<{ data: ProviderRepository }>>(); +const mockListBranches = jest.fn<() => Promise<{ data: { name: string }[] }>>(); +const mockGetIntegration = + jest.fn<(owner: Owner, integrationId: string) => Promise>(); + +jest.mock('@octokit/rest', () => ({ + Octokit: jest.fn(() => ({ + apps: { listReposAccessibleToInstallation: mockListRepositories }, + repos: { get: mockGetRepository, listBranches: mockListBranches }, + })), +})); +jest.mock('@octokit/auth-app', () => ({ + createAppAuth: () => async () => ({ token: 'test-installation-token', expiresAt: null }), +})); +jest.mock('./app-selector', () => ({ + getGitHubAppCredentials: () => ({ appId: 'test-app', privateKey: 'test-key' }), +})); +jest.mock('@/lib/utils.server', () => ({})); +jest.mock('@/lib/integrations/db/platform-integrations', () => ({ + getGitHubIntegrationById: mockGetIntegration, +})); +jest.mock('@/lib/integrations/platforms/github/adapter', () => + jest.requireActual('./adapter') +); +jest.mock('@/components/cloud-agent/demo-config', () => ({})); + +let fetchGitHubRepositories: typeof Adapter.fetchGitHubRepositories; +let fetchGitHubBranches: typeof Adapter.fetchGitHubBranches; +let listGitHubRepositoryBranches: typeof Helpers.listGitHubRepositoryBranches; + +beforeAll(async () => { + ({ fetchGitHubRepositories, fetchGitHubBranches } = await import('./adapter')); + ({ listGitHubRepositoryBranches } = await import('@/lib/cloud-agent/github-integration-helpers')); +}); + +beforeEach(() => { + jest.clearAllMocks(); + mockListRepositories.mockResolvedValue({ data: { repositories: [repository] } }); + mockGetRepository.mockResolvedValue({ data: repository }); + mockListBranches.mockResolvedValue({ + data: [{ name: 'feature/Case' }, { name: 'release/Case' }], + }); + mockGetIntegration.mockResolvedValue({ + id: 'integration-1', + platform: 'github', + platform_installation_id: 'installation-1', + github_app_type: 'standard', + integration_status: 'active', + suspended_at: null, + auth_invalid_at: null, + repositories: [repository], + } as PlatformIntegration); +}); + +describe('GitHub discovery adapter', () => { + it('retains the provider default alongside legacy repository fields', async () => { + await expect(fetchGitHubRepositories('installation-1')).resolves.toEqual([ + { + id: 42, + name: 'API', + full_name: 'acme/API', + private: true, + created_at: repository.created_at, + default_branch: 'release/Case', + }, + ]); + }); + + it.each([undefined, null])('does not guess an unavailable provider default: %s', async value => { + mockListRepositories.mockResolvedValue({ + data: { repositories: [{ ...repository, default_branch: value }] }, + }); + const repositories = await fetchGitHubRepositories('installation-1'); + expect(repositories[0]).toMatchObject({ id: 42, full_name: 'acme/API' }); + expect(repositories[0].default_branch).toBeUndefined(); + }); +}); + +describe('GitHub branch identity', () => { + it.each([ + { type: 'user', id: 'oauth/user' }, + { type: 'org', id: 'organization-1' }, + ])('rejects a live same-name replacement under a cached $type identity', async owner => { + mockGetRepository.mockResolvedValue({ data: { ...repository, id: 99 } }); + await expect( + listGitHubRepositoryBranches(owner, { + repository: { + provider: 'github', + instanceUrl: 'https://github.com', + repositoryId: '42', + fullName: 'acme/API', + defaultBranch: 'release/Case', + }, + authorization: { kind: 'ownerIntegration', owner, integrationId: 'integration-1' }, + }) + ).rejects.toMatchObject({ code: 'NOT_FOUND', message: 'GitHub repository not found' }); + }); + + it('returns case-preserving branches for the pinned live repository', async () => { + await expect( + fetchGitHubBranches('installation-1', 'acme/API', 'standard', '42') + ).resolves.toEqual([ + { name: 'feature/Case', isDefault: false }, + { name: 'release/Case', isDefault: true }, + ]); + }); + + it.each(['standard', 'lite'] as const)('preserves an unpinned %s caller', async appType => { + mockGetRepository.mockResolvedValue({ data: { ...repository, id: 99 } }); + await expect(fetchGitHubBranches('installation-1', 'acme/API', appType)).resolves.toEqual([ + { name: 'feature/Case', isDefault: false }, + { name: 'release/Case', isDefault: true }, + ]); + }); + + it('returns an empty branch list for an empty repository', async () => { + mockListBranches.mockResolvedValue({ data: [] }); + await expect( + fetchGitHubBranches('installation-1', 'acme/API', 'standard', '42') + ).resolves.toEqual([]); + }); + + it('does not invent a default when the provider has none', async () => { + mockGetRepository.mockResolvedValue({ data: { ...repository, default_branch: null } }); + await expect( + fetchGitHubBranches('installation-1', 'acme/API', 'standard', '42') + ).resolves.toEqual([ + { name: 'feature/Case', isDefault: false }, + { name: 'release/Case', isDefault: false }, + ]); + }); + + it('preserves a later-page failure instead of returning incomplete branches', async () => { + const error = new Error('GitHub page unavailable'); + mockListBranches + .mockResolvedValueOnce({ + data: Array.from({ length: 100 }, (_, i) => ({ name: `branch-${i}` })), + }) + .mockRejectedValueOnce(error); + await expect(fetchGitHubBranches('installation-1', 'acme/API', 'standard', '42')).rejects.toBe( + error + ); + }); +}); diff --git a/apps/web/src/lib/integrations/platforms/github/adapter.ts b/apps/web/src/lib/integrations/platforms/github/adapter.ts index b1d7d93f76..edecd0f784 100644 --- a/apps/web/src/lib/integrations/platforms/github/adapter.ts +++ b/apps/web/src/lib/integrations/platforms/github/adapter.ts @@ -1,6 +1,7 @@ import { Octokit } from '@octokit/rest'; import { createAppAuth } from '@octokit/auth-app'; import { exchangeWebFlowCode } from '@octokit/oauth-methods'; +import { TRPCError } from '@trpc/server'; import { logExceptInTest, warnExceptInTest } from '@/lib/utils.server'; import crypto from 'crypto'; @@ -123,6 +124,7 @@ type GitHubRepository = { full_name: string; private: boolean; created_at: string; + default_branch?: string; }; type GitHubBranch = { @@ -162,6 +164,7 @@ export async function fetchGitHubRepositories( full_name: repo.full_name, private: repo.private, created_at: repo.created_at ?? new Date().toISOString(), + default_branch: repo.default_branch ?? undefined, })) ); @@ -179,18 +182,24 @@ export async function fetchGitHubRepositories( export async function fetchGitHubBranches( installationId: string, repositoryFullName: string, - appType: GitHubAppType = 'standard' + appType: GitHubAppType = 'standard', + expectedRepositoryId?: string ): Promise { const tokenData = await generateGitHubInstallationToken(installationId, appType); const octokit = new Octokit({ auth: tokenData.token }); const [owner, repo] = repositoryFullName.split('/'); - // Fetch the repository to get the default branch + // Fetch the repository to get the default branch and validate its live identity. const { data: repoData } = await octokit.repos.get({ owner, repo, }); + // Old callers supply only a repository name. Keep that form until old clients + // and records disappear and the 30-day ledger window expires. + if (expectedRepositoryId !== undefined && String(repoData.id) !== expectedRepositoryId) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'GitHub repository not found' }); + } const defaultBranch = repoData.default_branch; // Fetch all branches using pagination From 18c82d0e5c10415e1dd0bf6ed507453b0e85bbf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 29 Aug 2026 12:52:43 +0200 Subject: [PATCH 2/3] feat(provider-review): expose exact repositories and branches --- .../cloud-agent-next/cloud-agent-client.ts | 2 + .../bitbucket/oauth-integration.test.ts | 487 +++++++++++ .../platforms/bitbucket/oauth-integration.ts | 144 +++- .../bitbucket/repository-cache.test.ts | 6 +- .../platforms/bitbucket/repository-cache.ts | 14 +- .../bitbucket/token-service-client.test.ts | 139 +++- .../bitbucket/token-service-client.ts | 53 +- ...pace-access-token-repository-cache.test.ts | 14 +- ...workspace-access-token-repository-cache.ts | 31 +- .../src/routers/cloud-agent-next-router.ts | 59 +- .../src/routers/cloud-agent-next-schemas.ts | 66 +- .../organization-cloud-agent-next-router.ts | 106 ++- .../provider-repository-contract.test.ts | 772 ++++++++++++++++++ .../src/cloud-agent-next-client.test.ts | 77 ++ .../src/cloud-agent-next-client.ts | 2 + 15 files changed, 1916 insertions(+), 56 deletions(-) create mode 100644 apps/web/src/lib/integrations/platforms/bitbucket/oauth-integration.test.ts create mode 100644 apps/web/src/routers/provider-repository-contract.test.ts diff --git a/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts b/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts index 6f83af9772..4681efb53b 100644 --- a/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts +++ b/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts @@ -106,6 +106,8 @@ type PrepareSessionSharedFields = { gitToken?: string; /** Explicit platform type for correct env var setup (avoids URL-based detection) */ platform?: 'github' | 'gitlab' | 'bitbucket'; + gitlabIntegrationId?: string; + bitbucketIntegrationId?: string; bitbucketWorkspaceUuid?: string; bitbucketRepositoryUuid?: string; // Common params diff --git a/apps/web/src/lib/integrations/platforms/bitbucket/oauth-integration.test.ts b/apps/web/src/lib/integrations/platforms/bitbucket/oauth-integration.test.ts new file mode 100644 index 0000000000..80eb19efb4 --- /dev/null +++ b/apps/web/src/lib/integrations/platforms/bitbucket/oauth-integration.test.ts @@ -0,0 +1,487 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { + createBitbucketInteractiveApi, + type BitbucketInteractiveRequest, +} from '../../../../../../../services/git-token-service/src/bitbucket-interactive-api'; +import { + BitbucketApiError, + BitbucketInteractiveError, +} from '../../../../../../../services/git-token-service/src/bitbucket-safe-transport'; + +const ORGANIZATION_ID = '11111111-1111-4111-8111-111111111111'; +const INTEGRATION_ID = '22222222-2222-4222-8222-222222222222'; +const WORKSPACE_UUID = '33333333-3333-4333-8333-333333333333'; +const REPOSITORY_UUID = '44444444-4444-4444-8444-444444444444'; +const REPLACEMENT_ID = '55555555-5555-4555-8555-555555555555'; +const USER_ID = 'oauth/actor'; +const OWNER = { type: 'org' as const, id: ORGANIZATION_ID }; +const TIMESTAMP = '2026-08-29 01:02:03.123+00'; +const cachedRepository = { + id: REPOSITORY_UUID, + name: 'API', + full_name: 'acme/API', + private: true, + default_branch: 'release/Case', +}; +const repository = { + id: REPOSITORY_UUID, + workspaceUuid: WORKSPACE_UUID, + name: 'API', + fullName: 'acme/API', + private: true, + defaultBranch: 'release/Case', +}; +const mockRows: unknown[][] = []; +const mockUpdateRows: unknown[][] = []; +const writes: Record[] = []; + +function query() { + const result: Record = { + from: () => result, + leftJoin: () => result, + innerJoin: () => result, + where: () => result, + limit: async () => mockRows.shift() ?? [], + then: (resolve: (rows: unknown[]) => unknown, reject: (error: unknown) => unknown) => + Promise.resolve(mockRows.shift() ?? []).then(resolve, reject), + }; + return result; +} +const mockDb: { select: jest.Mock; update: jest.Mock; transaction: jest.Mock } = { + select: jest.fn(query), + update: jest.fn(() => ({ + set: (value: Record) => { + writes.push(value); + return { + where: () => ({ + returning: async () => mockUpdateRows.shift() ?? [{ id: INTEGRATION_ID }], + }), + }; + }, + })), + transaction: jest.fn(async (callback: (tx: unknown) => Promise) => callback(mockDb)), +}; + +jest.mock('@/lib/drizzle', () => ({ db: mockDb })); +jest.mock('@/lib/config.server', () => ({ + GIT_TOKEN_SERVICE_API_URL: 'https://token-service.test', +})); +jest.mock('@/lib/tokens', () => ({ + generateInternalServiceToken: () => 'test-token', + TOKEN_EXPIRY: { fiveMinutes: 300 }, + BITBUCKET_REPOSITORY_LIST_AUDIENCE: 'repositories', +})); +jest.mock('next/server', () => ({ after: jest.fn() })); +jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn(), captureMessage: jest.fn() })); +jest.mock('./workspace-access-token-organization-authorization', () => ({ + lockBitbucketWorkspaceAccessTokenOrganization: async () => undefined, + requireBitbucketWorkspaceAccessTokenOrganizationManager: async () => undefined, + BitbucketWorkspaceAccessTokenOrganizationAuthorizationError: class extends Error {}, +})); + +function oauthRow(integrationId = INTEGRATION_ID) { + return { + integrationId, + integrationStatus: 'active', + installationId: WORKSPACE_UUID, + accountId: WORKSPACE_UUID, + accountLogin: 'acme', + metadata: { state: 'active', workspace: { uuid: WORKSPACE_UUID, slug: 'acme', name: 'Acme' } }, + repositories: [cachedRepository], + repositoriesSyncedAt: TIMESTAMP, + nickname: 'provider-user', + credentialId: 'credential', + revokedAt: null, + credential: { + id: 'credential', + platform_integration_id: integrationId, + authorized_by_user_id: USER_ID, + provider_subject_id: 'provider-user-id', + provider_subject_login: 'provider-user', + provider_base_url: null, + access_token_encrypted: 'encrypted', + access_token_expires_at: null, + refresh_token_encrypted: 'encrypted-refresh', + refresh_token_expires_at: null, + oauth_client_secret_encrypted: null, + credential_version: 1, + revoked_at: null, + revocation_reason: null, + last_used_at: null, + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + }, + }; +} +function workspaceRow(integrationId = INTEGRATION_ID) { + return { + integrationId, + integrationStatus: 'active', + installationId: null, + workspaceUuid: WORKSPACE_UUID, + workspaceSlug: 'acme', + metadata: { displayName: 'Acme' }, + repositories: [cachedRepository], + repositoriesSyncedAt: TIMESTAMP, + authInvalidAt: null, + authInvalidReason: null, + credential: { + id: 'credential', + platform_integration_id: integrationId, + token_encrypted: 'encrypted', + expires_at: null, + provider_credential_type: 'workspace_access_token', + provider_resource_id: null, + provider_base_url: null, + authorized_by_user_id: null, + provider_metadata: null, + provider_scopes: ['account', 'repository', 'repository:write', 'pullrequest', 'webhook'], + provider_verified_at: TIMESTAMP, + credential_version: 1, + last_validated_at: TIMESTAMP, + last_used_at: null, + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + }, + }; +} + +import type * as OAuthModule from './oauth-integration'; +import type * as CacheModule from './repository-cache'; +import type * as WorkspaceModule from './workspace-access-token-repository-cache'; +import type * as TokenModule from './token-service-client'; + +let oauth: typeof OAuthModule; +let cache: typeof CacheModule; +let workspace: typeof WorkspaceModule; +let token: typeof TokenModule; +const originalFetch = global.fetch; +beforeAll(async () => { + oauth = await import('./oauth-integration'); + cache = await import('./repository-cache'); + workspace = await import('./workspace-access-token-repository-cache'); + token = await import('./token-service-client'); +}); +beforeEach(() => { + mockRows.length = 0; + mockUpdateRows.length = 0; + writes.length = 0; + global.fetch = jest + .fn() + .mockResolvedValue(Response.json({ status: 'available', repositories: [repository] })); +}); +afterEach(() => { + global.fetch = originalFetch; +}); + +function expectIdentity(value: unknown, integrationId = INTEGRATION_ID) { + expect(value).toMatchObject({ + status: 'available', + repositories: [ + { + platformIntegrationId: integrationId, + repositoryReference: { + repository: { + provider: 'bitbucket', + instanceUrl: 'https://bitbucket.org', + repositoryId: REPOSITORY_UUID, + workspaceUuid: WORKSPACE_UUID, + fullName: 'acme/API', + defaultBranch: 'release/Case', + }, + authorization: { kind: 'ownerIntegration', owner: OWNER, integrationId }, + }, + }, + ], + }); +} + +describe('Bitbucket discovery producer identity', () => { + it('normalizes an old OAuth cache row in the producing lookup', async () => { + mockRows.push([oauthRow()]); + const result = await cache.listBitbucketRepositories({ owner: OWNER, kiloUserId: USER_ID }); + expectIdentity(result); + expect(result).toMatchObject({ syncedAt: '2026-08-29T01:02:03.123Z' }); + }); + it('normalizes an old workspace cache row in the producing lookup', async () => { + mockRows.push([workspaceRow()]); + expectIdentity( + await workspace.readCachedBitbucketWorkspaceAccessTokenRepositories({ + organizationId: ORGANIZATION_ID, + }) + ); + }); + it('retains identity in the OAuth status cache projection', async () => { + mockRows.push([oauthRow()]); + const result = await oauth.getBitbucketOAuthIntegrationStatus(OWNER, true); + expectIdentity(result?.repositoryCache); + }); + it('retains identity in the workspace status cache projection', async () => { + mockRows.push([workspaceRow()]); + const result = await workspace.getBitbucketWorkspaceAccessTokenStatus(ORGANIZATION_ID); + expectIdentity(result.repositoryCache); + }); + it('returns fresh OAuth identity while storing only the old cache row shape', async () => { + mockRows.push([oauthRow()]); + expectIdentity( + await cache.listBitbucketRepositories({ + owner: OWNER, + kiloUserId: USER_ID, + forceRefresh: true, + }) + ); + expect(writes[0].repositories).toEqual([cachedRepository]); + }); + it('returns fresh workspace identity while storing only the old cache row shape', async () => { + mockRows.push( + [workspaceRow()], + [{ integrationId: INTEGRATION_ID, credentialId: 'credential', credentialVersion: 1 }] + ); + expectIdentity( + await workspace.refreshBitbucketWorkspaceAccessTokenRepositories({ + organizationId: ORGANIZATION_ID, + kiloUserId: USER_ID, + expectedIntegrationId: INTEGRATION_ID, + }) + ); + expect(writes[0].repositories).toEqual([cachedRepository]); + }); + it('labels a replacement OAuth cache with the replacement identity, not the stale lookup', async () => { + mockRows.push([oauthRow()], [oauthRow(REPLACEMENT_ID)]); + mockUpdateRows.push([]); + expectIdentity( + await cache.listBitbucketRepositories({ + owner: OWNER, + kiloUserId: USER_ID, + forceRefresh: true, + }), + REPLACEMENT_ID + ); + }); + it('does not drop an explicit OAuth pin after a replacement race', async () => { + mockRows.push([oauthRow()], [oauthRow(REPLACEMENT_ID)]); + mockUpdateRows.push([]); + await expect( + cache.listBitbucketRepositories({ + owner: OWNER, + kiloUserId: USER_ID, + forceRefresh: true, + expectedIntegrationId: INTEGRATION_ID, + }) + ).resolves.toEqual({ status: 'temporarily_unavailable' }); + }); + it('labels a replacement workspace cache with the winning identity', async () => { + mockRows.push( + [workspaceRow()], + [ + { + integrationId: REPLACEMENT_ID, + credentialId: 'replacement-credential', + credentialVersion: 1, + }, + ], + [workspaceRow(REPLACEMENT_ID)] + ); + expectIdentity( + await workspace.refreshBitbucketWorkspaceAccessTokenRepositories({ + organizationId: ORGANIZATION_ID, + kiloUserId: USER_ID, + expectedIntegrationId: INTEGRATION_ID, + }), + REPLACEMENT_ID + ); + expect(writes).toEqual([]); + }); + it('keeps an unavailable default nullable in old cache rows', async () => { + const row = oauthRow(); + mockRows.push([ + { + ...row, + repositories: [{ id: REPOSITORY_UUID, name: 'API', full_name: 'acme/API', private: true }], + }, + ]); + const result = await cache.listBitbucketRepositories({ owner: OWNER, kiloUserId: USER_ID }); + expect(result).toMatchObject({ + repositories: [{ repositoryReference: { repository: { defaultBranch: null } } }], + }); + }); + it('distinguishes an initialized empty cache from a missing integration', async () => { + mockRows.push([{ ...oauthRow(), repositories: [] }], []); + await expect( + cache.listBitbucketRepositories({ owner: OWNER, kiloUserId: USER_ID }) + ).resolves.toEqual({ + status: 'available', + repositories: [], + syncedAt: '2026-08-29T01:02:03.123Z', + }); + await expect( + cache.listBitbucketRepositories({ owner: OWNER, kiloUserId: USER_ID }) + ).resolves.toEqual({ status: 'not_connected' }); + }); + it('preserves cached rows when a provider refresh fails', async () => { + mockRows.push([oauthRow()], [oauthRow()]); + global.fetch = jest + .fn() + .mockResolvedValue(Response.json({ status: 'temporarily_unavailable' })); + await expect( + cache.listBitbucketRepositories({ owner: OWNER, kiloUserId: USER_ID, forceRefresh: true }) + ).resolves.toEqual({ status: 'temporarily_unavailable' }); + expectIdentity(await cache.listBitbucketRepositories({ owner: OWNER, kiloUserId: USER_ID })); + expect(writes).toEqual([]); + }); + it('rejects a stale workspace pin without substituting a same-name repository', async () => { + mockRows.push([workspaceRow(REPLACEMENT_ID)]); + await expect( + workspace.readCachedBitbucketWorkspaceAccessTokenRepositories({ + organizationId: ORGANIZATION_ID, + expectedIntegrationId: INTEGRATION_ID, + }) + ).resolves.toEqual({ status: 'invalid_request' }); + }); +}); + +function useInteractiveService(providerFetch: typeof fetch) { + const api = createBitbucketInteractiveApi({ + scope: { kind: 'repository', workspace: 'acme', repository: 'API' }, + accessToken: 'provider-test-token', + fetch: providerFetch, + }); + global.fetch = jest.fn(async (_url, init) => { + const body = JSON.parse(String(init?.body)) as { + request: BitbucketInteractiveRequest<'branches'>; + }; + try { + return Response.json({ + success: true, + result: await api.execute(body.request), + metadata: { + actorUserId: USER_ID, + organizationId: ORGANIZATION_ID, + integrationId: INTEGRATION_ID, + instanceUrl: 'https://bitbucket.org', + providerActor: { + credentialKind: 'bitbucketWorkspaceToken', + workspaceUuid: WORKSPACE_UUID, + workspaceSlug: 'acme', + }, + grants: { scopes: ['repository', 'pullrequest'] }, + }, + }); + } catch (error) { + if (!(error instanceof BitbucketApiError || error instanceof BitbucketInteractiveError)) + throw error; + return Response.json({ success: false, reason: error.code }); + } + }); +} +function branchesInput(defaultBranch: string | undefined = 'release/Case') { + return { + actorUserId: USER_ID, + organizationId: ORGANIZATION_ID, + reference: token.withBitbucketRepositoryIdentity( + { ...repository, defaultBranch }, + OWNER, + INTEGRATION_ID + ).repositoryReference, + }; +} + +describe('Bitbucket branch boundary through the b1/a3 transport', () => { + const next = + 'https://api.bitbucket.org/2.0/repositories/acme/API/refs/branches?pagelen=50&page=2'; + it('uses validated pagination and the selected repository default', async () => { + useInteractiveService( + jest.fn().mockResolvedValue( + Response.json({ + values: [{ name: 'release/Case' }, { name: 'feature/Case' }], + pagelen: 50, + next, + }) + ) + ); + await expect(oauth.listBitbucketRepositoryBranches(branchesInput())).resolves.toEqual({ + branches: [ + { name: 'release/Case', isDefault: true }, + { name: 'feature/Case', isDefault: false }, + ], + defaultBranch: 'release/Case', + nextCursor: next, + }); + }); + it('preserves the first page while a failed page can be retried', async () => { + let pageCalls = 0; + useInteractiveService( + jest.fn(async url => { + if (!String(url).includes('page=2')) + return Response.json({ values: [{ name: 'release/Case' }], next }); + pageCalls += 1; + return pageCalls === 1 + ? new Response('', { status: 503 }) + : Response.json({ values: [{ name: 'feature/retry' }] }); + }) + ); + const input = branchesInput(); + const first = await oauth.listBitbucketRepositoryBranches(input); + await expect( + oauth.listBitbucketRepositoryBranches({ ...input, cursor: next }) + ).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE' }); + await expect( + oauth.listBitbucketRepositoryBranches({ ...input, cursor: next }) + ).resolves.toMatchObject({ + branches: [{ name: 'feature/retry', isDefault: false }], + nextCursor: null, + }); + expect(first).toEqual({ + branches: [{ name: 'release/Case', isDefault: true }], + defaultBranch: 'release/Case', + nextCursor: next, + }); + }); + it.each([ + 'https://attacker.test/branches?page=2', + 'https://api.bitbucket.org/2.0/repositories/other/API/refs/branches?pagelen=50&page=2', + ])('rejects an out-of-scope next URL: %s', async invalidNext => { + useInteractiveService( + jest.fn().mockResolvedValue(Response.json({ values: [], next: invalidNext })) + ); + await expect(oauth.listBitbucketRepositoryBranches(branchesInput())).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + it('does not guess a default in an empty repository', async () => { + useInteractiveService(jest.fn().mockResolvedValue(Response.json({ values: [] }))); + const input = branchesInput(); + input.reference.repository.defaultBranch = null; + await expect(oauth.listBitbucketRepositoryBranches(input)).resolves.toEqual({ + branches: [], + defaultBranch: null, + nextCursor: null, + }); + }); + it('does not treat a branch named main as an unknown default', async () => { + useInteractiveService( + jest.fn().mockResolvedValue(Response.json({ values: [{ name: 'main' }] })) + ); + const input = branchesInput(); + input.reference.repository.defaultBranch = null; + await expect(oauth.listBitbucketRepositoryBranches(input)).resolves.toMatchObject({ + branches: [{ name: 'main', isDefault: false }], + defaultBranch: null, + }); + }); + it('rejects a different owner before requesting provider branches', async () => { + const input = branchesInput(); + input.reference.authorization.owner = { type: 'user', id: USER_ID }; + await expect(oauth.listBitbucketRepositoryBranches(input)).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + }); + it('distinguishes denied provider access from a retryable page failure', async () => { + useInteractiveService( + jest.fn().mockResolvedValue(new Response('', { status: 403 })) + ); + await expect(oauth.listBitbucketRepositoryBranches(branchesInput())).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + }); +}); diff --git a/apps/web/src/lib/integrations/platforms/bitbucket/oauth-integration.ts b/apps/web/src/lib/integrations/platforms/bitbucket/oauth-integration.ts index fad17511a4..a37cd8687d 100644 --- a/apps/web/src/lib/integrations/platforms/bitbucket/oauth-integration.ts +++ b/apps/web/src/lib/integrations/platforms/bitbucket/oauth-integration.ts @@ -11,7 +11,12 @@ import { listBitbucketRepositories, } from './repository-cache'; import { BitbucketIntegrationMetadataSchema, type BitbucketWorkspace } from './metadata'; -import { BitbucketRepositorySchema } from './token-service-client'; +import { BitbucketRepositorySchema, withBitbucketRepositoryIdentity } from './token-service-client'; +import type { LaunchRepositoryReference } from '@kilocode/app-shared/code-review/repository-identity'; +import { + createBitbucketInteractiveClient, + BitbucketInteractiveClientError, +} from './interactive-client'; import { platform_integrations, platform_oauth_credentials } from '@kilocode/db/schema'; const CachedRepositorySchema = z @@ -56,7 +61,9 @@ function emptyRepositoryCache() { function readCachedRepositories( value: unknown, syncedAt: string | null, - workspace: BitbucketWorkspace + workspace: BitbucketWorkspace, + owner: Owner, + integrationId: string ) { if (value === null || syncedAt === null) return emptyRepositoryCache(); const repositories = z.array(CachedRepositorySchema).safeParse(value); @@ -64,14 +71,20 @@ function readCachedRepositories( return { status: 'available' as const, - repositories: repositories.data.map(repository => ({ - id: repository.id, - workspaceUuid: workspace.uuid, - name: repository.name, - fullName: repository.full_name, - private: repository.private, - defaultBranch: repository.default_branch, - })), + repositories: repositories.data.map(repository => + withBitbucketRepositoryIdentity( + { + id: repository.id, + workspaceUuid: workspace.uuid, + name: repository.name, + fullName: repository.full_name, + private: repository.private, + defaultBranch: repository.default_branch, + }, + owner, + integrationId + ) + ), syncedAt: new Date(syncedAt).toISOString(), }; } @@ -158,7 +171,9 @@ export async function getBitbucketOAuthIntegrationStatus(owner: Owner, canManage repositoryCache: readCachedRepositories( row.repositories, row.repositoriesSyncedAt, - metadata.data.workspace + metadata.data.workspace, + owner, + row.integrationId ), }; } @@ -254,6 +269,113 @@ export async function refreshBitbucketOAuthRepositories(input: { }); } +export async function listBitbucketRepositoryBranches(input: { + actorUserId: string; + organizationId: string; + reference: LaunchRepositoryReference; + cursor?: string; +}) { + const { repository, authorization } = input.reference; + if ( + repository.provider !== 'bitbucket' || + authorization.owner.type !== 'org' || + authorization.owner.id !== input.organizationId + ) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Bitbucket repositories require the selected organization', + }); + } + const [workspaceSlug, repositorySlug, extra] = repository.fullName.split('/'); + if ( + !workspaceSlug || + !repositorySlug || + extra || + repository.instanceUrl !== 'https://bitbucket.org' + ) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid Bitbucket repository' }); + } + const client = createBitbucketInteractiveClient({ + actorUserId: input.actorUserId, + organizationId: input.organizationId, + workspace: { + integrationId: authorization.integrationId, + workspaceUuid: repository.workspaceUuid, + workspaceSlug, + }, + repository: { + repositoryUuid: repository.repositoryId, + repositoryFullName: repository.fullName, + }, + }); + try { + const result = await client.execute({ + operation: 'branches', + params: { + path: { workspace: workspaceSlug, repo_slug: repositorySlug }, + query: { pagelen: 50 }, + }, + ...(input.cursor ? { next: input.cursor } : {}), + }); + if ( + result.status !== 200 || + result.metadata.integrationId !== authorization.integrationId || + result.metadata.organizationId !== input.organizationId || + result.metadata.actorUserId !== input.actorUserId + ) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'Bitbucket integration changed', + }); + } + const page = z + .object({ values: z.array(z.object({ name: z.string().min(1) })).max(50) }) + .safeParse(result.data); + if (!page.success) + throw new TRPCError({ + code: 'SERVICE_UNAVAILABLE', + message: 'Failed to fetch Bitbucket branches', + }); + return { + branches: page.data.values.map(branch => ({ + name: branch.name, + isDefault: branch.name === repository.defaultBranch, + })), + defaultBranch: repository.defaultBranch, + // The b1/a3 service validates next links against this exact endpoint and query. + nextCursor: result.next ?? null, + }; + } catch (error) { + if (error instanceof TRPCError) throw error; + if (error instanceof BitbucketInteractiveClientError) { + if ( + [ + 'not_connected', + 'reconnect_required', + 'insufficient_permissions', + 'authentication_rejected', + ].includes(error.code) + ) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Bitbucket repository access denied' }); + } + if ( + ['integration_mismatch', 'workspace_mismatch', 'repository_mismatch', 'not_found'].includes( + error.code + ) + ) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Bitbucket repository not found' }); + } + if (['invalid_request', 'invalid_pagination'].includes(error.code)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid Bitbucket branch request' }); + } + } + throw new TRPCError({ + code: 'SERVICE_UNAVAILABLE', + message: 'Failed to fetch Bitbucket branches', + }); + } +} + export const BitbucketOrganizationRepositoryListResultSchema = z.discriminatedUnion('status', [ z .object({ diff --git a/apps/web/src/lib/integrations/platforms/bitbucket/repository-cache.test.ts b/apps/web/src/lib/integrations/platforms/bitbucket/repository-cache.test.ts index d680d7d7d7..58c2549d8e 100644 --- a/apps/web/src/lib/integrations/platforms/bitbucket/repository-cache.test.ts +++ b/apps/web/src/lib/integrations/platforms/bitbucket/repository-cache.test.ts @@ -21,6 +21,7 @@ import { createTestOrganization } from '@/tests/helpers/organization.helper'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { eq } from 'drizzle-orm'; import type { BitbucketRepositoryListResult } from './token-service-client'; +import type * as TokenServiceClientModule from './token-service-client'; import type { listBitbucketRepositories as ListBitbucketRepositories, primeBitbucketRepositoryCache as PrimeBitbucketRepositoryCache, @@ -32,6 +33,7 @@ const mockFetchBitbucketRepositoriesFromTokenService = >(); jest.mock('./token-service-client', () => ({ + ...jest.requireActual('./token-service-client'), fetchBitbucketRepositoriesFromTokenService: mockFetchBitbucketRepositoriesFromTokenService, })); @@ -154,7 +156,7 @@ describe('Bitbucket repository cache', () => { owner: { type: 'user', id: user.id }, kiloUserId: user.id, }) - ).resolves.toEqual({ + ).resolves.toMatchObject({ status: 'available', repositories: [ { @@ -195,7 +197,7 @@ describe('Bitbucket repository cache', () => { owner: { type: 'user', id: user.id }, kiloUserId: user.id, }) - ).resolves.toEqual({ + ).resolves.toMatchObject({ status: 'available', repositories: [], syncedAt: CACHED_AT, diff --git a/apps/web/src/lib/integrations/platforms/bitbucket/repository-cache.ts b/apps/web/src/lib/integrations/platforms/bitbucket/repository-cache.ts index ef76c3ffc8..5c1db1fb17 100644 --- a/apps/web/src/lib/integrations/platforms/bitbucket/repository-cache.ts +++ b/apps/web/src/lib/integrations/platforms/bitbucket/repository-cache.ts @@ -13,6 +13,7 @@ import { BitbucketIntegrationMetadataSchema, type BitbucketWorkspace } from './m import { BitbucketRepositorySchema, fetchBitbucketRepositoriesFromTokenService, + withBitbucketRepositoryIdentity, } from './token-service-client'; const CachedBitbucketRepositorySchema = z @@ -147,7 +148,13 @@ export async function listBitbucketRepositories({ row.repositoriesSyncedAt, metadata.data.workspace ); - if (!forceRefresh && cachedResult) return cachedResult; + if (!forceRefresh && cachedResult) + return { + ...cachedResult, + repositories: cachedResult.repositories.map(repository => + withBitbucketRepositoryIdentity(repository, owner, row.integrationId) + ), + }; const result = await fetchBitbucketRepositoriesFromTokenService( kiloUserId, @@ -194,11 +201,14 @@ export async function listBitbucketRepositories({ ) .returning({ id: platform_integrations.id }); if (!updated) { - return listBitbucketRepositories({ owner, kiloUserId }); + return listBitbucketRepositories({ owner, kiloUserId, expectedIntegrationId }); } return { ...result, + repositories: result.repositories.map(repository => + withBitbucketRepositoryIdentity(repository, owner, row.integrationId) + ), syncedAt, }; } diff --git a/apps/web/src/lib/integrations/platforms/bitbucket/token-service-client.test.ts b/apps/web/src/lib/integrations/platforms/bitbucket/token-service-client.test.ts index b3391ef601..839755230d 100644 --- a/apps/web/src/lib/integrations/platforms/bitbucket/token-service-client.test.ts +++ b/apps/web/src/lib/integrations/platforms/bitbucket/token-service-client.test.ts @@ -1,11 +1,130 @@ -import { describe, expect, it } from '@jest/globals'; -import { BitbucketRepositoryListResultSchema } from './token-service-client'; - -describe('BitbucketRepositoryListResultSchema', () => { - it.each(['insufficient_permissions', 'invalid_request'] as const)( - 'accepts the static token-service %s result', - status => { - expect(BitbucketRepositoryListResultSchema.parse({ status })).toEqual({ status }); - } - ); +import { afterEach, beforeAll, describe, expect, it, jest } from '@jest/globals'; +import type * as TokenModule from './token-service-client'; +import type * as CacheModule from './repository-cache'; +import type * as OAuthModule from './oauth-integration'; +import type * as WorkspaceModule from './workspace-access-token-repository-cache'; + +jest.mock('@/lib/config.server', () => ({ + GIT_TOKEN_SERVICE_API_URL: 'https://token-service.test', +})); +jest.mock('@/lib/tokens', () => ({ + generateInternalServiceToken: () => 'test-token', + TOKEN_EXPIRY: { fiveMinutes: 300 }, + BITBUCKET_REPOSITORY_LIST_AUDIENCE: 'repositories', +})); +jest.mock('@/lib/drizzle', () => ({ db: {} })); +jest.mock('next/server', () => ({ after: jest.fn() })); +jest.mock('./workspace-access-token-organization-authorization', () => ({})); + +let token: typeof TokenModule; +let cache: typeof CacheModule; +let oauth: typeof OAuthModule; +let workspace: typeof WorkspaceModule; +beforeAll(async () => { + token = await import('./token-service-client'); + cache = await import('./repository-cache'); + oauth = await import('./oauth-integration'); + workspace = await import('./workspace-access-token-repository-cache'); +}); +const repository = { + id: '11111111-1111-4111-8111-111111111111', + workspaceUuid: '22222222-2222-4222-8222-222222222222', + name: 'API', + fullName: 'acme/API', + private: true, +}; +const owner = { type: 'org' as const, id: '33333333-3333-4333-8333-333333333333' }; +const integrationId = '44444444-4444-4444-8444-444444444444'; +const originalFetch = global.fetch; +afterEach(() => { + global.fetch = originalFetch; +}); + +describe.each(['token service', 'OAuth cache', 'organization', 'workspace cache'] as const)( + '%s repository wire contract', + name => { + const timestamps = name === 'token service' ? {} : { syncedAt: '2026-08-29T00:00:00.000Z' }; + const getSchema = () => + name === 'token service' + ? token.BitbucketRepositoryListResultSchema + : name === 'OAuth cache' + ? cache.CachedBitbucketRepositoryListResultSchema + : name === 'organization' + ? oauth.BitbucketOrganizationRepositoryListResultSchema + : workspace.BitbucketWorkspaceAccessTokenRepositoryListResultSchema; + + it('accepts serialized old rows without inventing a default or integration', () => { + const payload = JSON.parse( + JSON.stringify({ status: 'available', repositories: [repository], ...timestamps }) + ); + expect(getSchema().parse(payload)).toEqual(payload); + }); + it('retains the producing integration and normalized nullable default', () => { + const row = token.withBitbucketRepositoryIdentity(repository, owner, integrationId); + const result = getSchema().parse( + JSON.parse(JSON.stringify({ status: 'available', repositories: [row], ...timestamps })) + ); + expect(result).toMatchObject({ + repositories: [ + { + id: repository.id, + platformIntegrationId: integrationId, + repositoryReference: { + repository: { + provider: 'bitbucket', + instanceUrl: 'https://bitbucket.org', + repositoryId: repository.id, + workspaceUuid: repository.workspaceUuid, + fullName: 'acme/API', + defaultBranch: null, + }, + authorization: { kind: 'ownerIntegration', owner, integrationId }, + }, + }, + ], + }); + }); + it.each([ + ['unknown field', { ...repository, token: 'must-not-escape' }], + ['malformed integration', { ...repository, platformIntegrationId: 'invalid' }], + ['wrong host', { ...repository, instanceUrl: 'https://attacker.test' }], + ])('rejects %s instead of weakening strict parsing', (_label, row) => { + expect( + getSchema().safeParse({ status: 'available', repositories: [row], ...timestamps }).success + ).toBe(false); + }); + it('rejects a normalized reference for a different repository', () => { + const row = { + ...token.withBitbucketRepositoryIdentity(repository, owner, integrationId), + id: owner.id, + }; + expect( + getSchema().safeParse({ status: 'available', repositories: [row], ...timestamps }).success + ).toBe(false); + }); + } +); + +describe('Bitbucket discovery transport', () => { + it.each([ + 'insufficient_permissions', + 'invalid_request', + 'temporarily_unavailable', + 'not_connected', + ] as const)('preserves %s without an empty-success substitution', async status => { + global.fetch = jest.fn().mockResolvedValue(Response.json({ status })); + await expect( + token.fetchBitbucketRepositoriesFromTokenService('oauth/user', owner.id) + ).resolves.toEqual({ status }); + }); + it('returns a retryable failure for malformed provider JSON', async () => { + global.fetch = jest + .fn() + .mockResolvedValue( + Response.json({ status: 'available', repositories: [{ ...repository, id: 12 }] }) + ); + await expect( + token.fetchBitbucketRepositoriesFromTokenService('oauth/user', owner.id) + ).resolves.toEqual({ status: 'temporarily_unavailable' }); + }); }); diff --git a/apps/web/src/lib/integrations/platforms/bitbucket/token-service-client.ts b/apps/web/src/lib/integrations/platforms/bitbucket/token-service-client.ts index ad1b01859d..b24f5163da 100644 --- a/apps/web/src/lib/integrations/platforms/bitbucket/token-service-client.ts +++ b/apps/web/src/lib/integrations/platforms/bitbucket/token-service-client.ts @@ -13,6 +13,12 @@ import { TOKEN_EXPIRY, } from '@/lib/tokens'; +import type { + LaunchRepositoryReference, + Owner, +} from '@kilocode/app-shared/code-review/repository-identity'; +import { launchRepositoryReferenceSchema } from '@/routers/cloud-agent-next-schemas'; + export const BitbucketRepositorySchema = z .object({ id: z.uuid(), @@ -21,8 +27,53 @@ export const BitbucketRepositorySchema = z fullName: z.string().min(3), private: z.boolean(), defaultBranch: z.string().min(1).optional(), + // Old token-service/cache responses omit identity additions. Remove this wire + // compatibility only after old clients/records and the 30-day ledger window expire. + platformIntegrationId: z.uuid().optional(), + instanceUrl: z.literal('https://bitbucket.org').optional(), + repositoryReference: launchRepositoryReferenceSchema.optional(), }) - .strict(); + .strict() + .refine(value => { + const reference = value.repositoryReference; + return ( + !reference || + (reference.repository.provider === 'bitbucket' && + reference.repository.instanceUrl === 'https://bitbucket.org' && + reference.repository.repositoryId === value.id && + reference.repository.workspaceUuid === value.workspaceUuid && + reference.repository.fullName === value.fullName && + reference.repository.defaultBranch === (value.defaultBranch ?? null) && + (value.platformIntegrationId === undefined || + reference.authorization.integrationId === value.platformIntegrationId)) + ); + }, 'Bitbucket repository identity mismatch'); + +export function withBitbucketRepositoryIdentity( + repository: z.infer, + owner: Owner, + integrationId: string +) { + const repositoryReference: LaunchRepositoryReference = { + repository: { + provider: 'bitbucket', + instanceUrl: 'https://bitbucket.org', + repositoryId: repository.id, + workspaceUuid: repository.workspaceUuid, + fullName: repository.fullName, + // Old cache rows omit default_branch. Keep unavailable until an exact refresh; + // remove the fallback after old rows/clients and the 30-day ledger window expire. + defaultBranch: repository.defaultBranch ?? null, + }, + authorization: { kind: 'ownerIntegration', owner, integrationId }, + }; + return { + ...repository, + platformIntegrationId: integrationId, + instanceUrl: 'https://bitbucket.org' as const, + repositoryReference, + }; +} export const BitbucketRepositoryListResultSchema = z.discriminatedUnion('status', [ z diff --git a/apps/web/src/lib/integrations/platforms/bitbucket/workspace-access-token-repository-cache.test.ts b/apps/web/src/lib/integrations/platforms/bitbucket/workspace-access-token-repository-cache.test.ts index b57eb3aa55..4a4d49acde 100644 --- a/apps/web/src/lib/integrations/platforms/bitbucket/workspace-access-token-repository-cache.test.ts +++ b/apps/web/src/lib/integrations/platforms/bitbucket/workspace-access-token-repository-cache.test.ts @@ -26,9 +26,7 @@ const mockFetchBitbucketRepositoriesFromTokenService = jest.fn<(kiloUserId: string, organizationId: string) => Promise>(); jest.mock('./token-service-client', () => ({ - BitbucketRepositorySchema: - jest.requireActual('./token-service-client') - .BitbucketRepositorySchema, + ...jest.requireActual('./token-service-client'), fetchBitbucketWorkspaceAccessTokenRepositoriesFromTokenService: mockFetchBitbucketRepositoriesFromTokenService, })); @@ -176,7 +174,7 @@ describe('Bitbucket Workspace Access Token repository cache', () => { readCachedBitbucketWorkspaceAccessTokenRepositories({ organizationId: organization.id, }) - ).resolves.toEqual({ + ).resolves.toMatchObject({ status: 'available', repositories: [ { @@ -603,7 +601,7 @@ describe('Bitbucket Workspace Access Token repository cache', () => { kiloUserId: user.id, expectedIntegrationId: integration.id, }) - ).resolves.toEqual({ + ).resolves.toMatchObject({ status: 'available', repositories: [REFRESHED_REPOSITORY], syncedAt: WINNER_CACHED_AT, @@ -661,7 +659,7 @@ describe('Bitbucket Workspace Access Token repository cache', () => { kiloUserId: user.id, expectedIntegrationId: integration.id, }) - ).resolves.toEqual({ + ).resolves.toMatchObject({ status: 'available', repositories: [REFRESHED_REPOSITORY], syncedAt: WINNER_CACHED_AT, @@ -712,7 +710,7 @@ describe('Bitbucket Workspace Access Token repository cache', () => { kiloUserId: user.id, expectedIntegrationId: integration.id, }) - ).resolves.toEqual({ + ).resolves.toMatchObject({ status: 'available', repositories: [REFRESHED_REPOSITORY], syncedAt: WINNER_CACHED_AT, @@ -809,7 +807,7 @@ describe('Bitbucket Workspace Access Token repository cache', () => { kiloUserId: user.id, expectedIntegrationId: integration.id, }) - ).resolves.toEqual({ + ).resolves.toMatchObject({ status: 'available', repositories: [ { diff --git a/apps/web/src/lib/integrations/platforms/bitbucket/workspace-access-token-repository-cache.ts b/apps/web/src/lib/integrations/platforms/bitbucket/workspace-access-token-repository-cache.ts index a3c3458671..42b3be3180 100644 --- a/apps/web/src/lib/integrations/platforms/bitbucket/workspace-access-token-repository-cache.ts +++ b/apps/web/src/lib/integrations/platforms/bitbucket/workspace-access-token-repository-cache.ts @@ -19,6 +19,7 @@ import { BitbucketWorkspaceAccessTokenMetadataSchema } from './metadata'; import { BitbucketRepositorySchema, fetchBitbucketWorkspaceAccessTokenRepositoriesFromTokenService, + withBitbucketRepositoryIdentity, } from './token-service-client'; import { BitbucketWorkspaceAccessTokenOrganizationAuthorizationError, @@ -201,7 +202,7 @@ function toIsoTimestamp(value: string | null): string | null { return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null; } -function parseIntegration(row: LoadedIntegration) { +function parseIntegration(row: LoadedIntegration, organizationId: string) { const metadata = BitbucketWorkspaceAccessTokenMetadataSchema.safeParse(row.metadata); const workspaceUuid = z.uuid().safeParse(row.workspaceUuid); const workspaceSlug = WorkspaceSlugSchema.safeParse(row.workspaceSlug); @@ -213,9 +214,21 @@ function parseIntegration(row: LoadedIntegration) { metadata.success && workspaceIdentity ? { ...workspaceIdentity, displayName: metadata.data.displayName } : null; - const cache = workspace + const parsedCache = workspace ? parseCachedRepositories(row.repositories, row.repositoriesSyncedAt, workspace) : null; + const cache = parsedCache + ? { + ...parsedCache, + repositories: parsedCache.repositories.map(repository => + withBitbucketRepositoryIdentity( + repository, + { type: 'org', id: organizationId }, + row.integrationId + ) + ), + } + : null; const parsedCredential = BitbucketWorkspaceAccessTokenCredentialRowSchema.safeParse( row.credential ); @@ -261,7 +274,7 @@ function parseIntegration(row: LoadedIntegration) { async function loadParsedIntegration(organizationId: string) { const row = await loadIntegration(organizationId); - return row ? parseIntegration(row) : null; + return row ? parseIntegration(row, organizationId) : null; } function isRefreshableIntegration( @@ -636,5 +649,15 @@ async function refreshLoadedBitbucketWorkspaceAccessTokenRepositories({ organizationId, }); } - return { ...providerResult, syncedAt }; + return { + ...providerResult, + repositories: providerResult.repositories.map(repository => + withBitbucketRepositoryIdentity( + repository, + { type: 'org', id: organizationId }, + integration.row.integrationId + ) + ), + syncedAt, + }; } diff --git a/apps/web/src/routers/cloud-agent-next-router.ts b/apps/web/src/routers/cloud-agent-next-router.ts index ccfa12fed2..4f3b830ebc 100644 --- a/apps/web/src/routers/cloud-agent-next-router.ts +++ b/apps/web/src/routers/cloud-agent-next-router.ts @@ -9,11 +9,20 @@ import { computeCloudAgentNextBalanceCheckEligibility } from '@/lib/cloud-agent- import { rethrowAsTerminalError } from '@/lib/cloud-agent-next/terminal-errors'; import { generateCloudAgentToken } from '@/lib/tokens'; import { isFeatureFlagEnabledOrDevelopment } from '@/lib/posthog-feature-flags'; -import { fetchGitHubRepositoriesForUser } from '@/lib/cloud-agent/github-integration-helpers'; +import { + fetchGitHubRepositoriesForUser, + listGitHubRepositoryBranches, +} from '@/lib/cloud-agent/github-integration-helpers'; +import { + launchRepositoryReferenceSchema, + listRepositoryBranchesInputSchema, + listRepositoryBranchesOutputSchema, +} from './cloud-agent-next-schemas'; import { getGitLabInstanceUrlForUser, buildGitLabCloneUrl, fetchGitLabRepositoriesForUser, + listGitLabRepositoryBranches, } from '@/lib/cloud-agent/gitlab-integration-helpers'; import { orderRepositoriesByUsage } from '@/lib/cloud-agent/order-repositories'; import { @@ -150,7 +159,8 @@ export const cloudAgentNextRouter = createTRPCRouter({ }); const client = createCloudAgentNextClientForModel(authToken, eligibility); - const { gitlabProject, githubRepo, attachments, images, ...restInput } = input; + const { gitlabProject, gitlabInstanceUrl, githubRepo, attachments, images, ...restInput } = + input; // Determine git source: GitLab uses gitUrl, GitHub uses githubRepo. // Tokens are resolved inside cloud-agent-next via GIT_TOKEN_SERVICE. @@ -164,7 +174,11 @@ export const cloudAgentNextRouter = createTRPCRouter({ }; if (gitlabProject) { - const instanceUrl = await getGitLabInstanceUrlForUser(ctx.user.id); + const instanceUrl = await getGitLabInstanceUrlForUser( + ctx.user.id, + input.gitlabIntegrationId, + gitlabInstanceUrl + ); const gitUrl = buildGitLabCloneUrl(gitlabProject, instanceUrl); gitParams = { gitUrl, platform: PLATFORM.GITLAB }; } else { @@ -514,6 +528,31 @@ export const cloudAgentNextRouter = createTRPCRouter({ ).getComputeBillingStatus(input.cloudAgentSessionId); }), + listBitbucketRepositories: baseProcedure.query(() => { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Bitbucket repositories require an organization', + }); + }), + + listRepositoryBranches: baseProcedure + .input(listRepositoryBranchesInputSchema) + .output(listRepositoryBranchesOutputSchema) + .query(async ({ ctx, input }) => { + const owner = { type: 'user' as const, id: ctx.user.id }; + if (input.repository.provider === 'bitbucket') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Bitbucket repositories require an organization', + }); + } + if (input.cursor) + throw new TRPCError({ code: 'BAD_REQUEST', message: 'This provider returns all branches' }); + return input.repository.provider === 'github' + ? listGitHubRepositoryBranches(owner, input) + : listGitLabRepositoryBranches(owner, ctx.user.id, input); + }), + checkEligibility: baseProcedure.query(async ({ ctx }) => { const { balance } = await getBalanceForUser(ctx.user); return buildCloudAgentNextEligibility(balance); @@ -536,6 +575,10 @@ export const cloudAgentNextRouter = createTRPCRouter({ name: z.string(), fullName: z.string(), private: z.boolean(), + platformIntegrationId: z.uuid().optional(), + platformAccountLogin: z.string().optional(), + instanceUrl: z.string().optional(), + repositoryReference: launchRepositoryReferenceSchema.optional(), defaultBranch: z.string().optional(), }) ), @@ -566,6 +609,7 @@ export const cloudAgentNextRouter = createTRPCRouter({ .input( z.object({ forceRefresh: z.boolean().optional().default(false), + integrationId: z.uuid().optional(), }) ) .output( @@ -576,6 +620,11 @@ export const cloudAgentNextRouter = createTRPCRouter({ name: z.string(), fullName: z.string(), private: z.boolean(), + platformIntegrationId: z.uuid().optional(), + platformAccountLogin: z.string().optional(), + instanceUrl: z.string().optional(), + repositoryReference: launchRepositoryReferenceSchema.optional(), + defaultBranch: z.string().optional(), }) ), integrationInstalled: z.boolean(), @@ -584,7 +633,9 @@ export const cloudAgentNextRouter = createTRPCRouter({ }) ) .query(async ({ ctx, input }) => { - const result = await fetchGitLabRepositoriesForUser(ctx.user.id, input.forceRefresh); + const result = input.integrationId + ? await fetchGitLabRepositoriesForUser(ctx.user.id, input.forceRefresh, input.integrationId) + : await fetchGitLabRepositoriesForUser(ctx.user.id, input.forceRefresh); return { repositories: await orderRepositoriesByUsage({ userId: ctx.user.id, diff --git a/apps/web/src/routers/cloud-agent-next-schemas.ts b/apps/web/src/routers/cloud-agent-next-schemas.ts index 0a0bb7e742..a624becca4 100644 --- a/apps/web/src/routers/cloud-agent-next-schemas.ts +++ b/apps/web/src/routers/cloud-agent-next-schemas.ts @@ -353,13 +353,49 @@ export const sendMessageNextSendPayloadSchema = z.discriminatedUnion('type', [ sendMessageNextPayloadSchema.options[1], ]); -/** - * Shared fields for prepareSession. Discriminated on `cloneFromKiloSessionId`: - * the non-clone variant keeps the required `prompt` and the current optional - * initial fields, while the clone-only variant requires the source session, - * `autoInitiate: true`, and a stable `operationKey`, and forbids any synthetic - * initial turn fields. - */ +// Syntax only. Provider helpers must compare this URL with the authorized integration. +const repositoryInstanceUrlSchema = z + .url({ protocol: /^https$/ }) + .regex(/^https:\/\/[^/?#@]+(?:\/[^?#]*)?$/); + +const repositoryIdentityFields = { + instanceUrl: repositoryInstanceUrlSchema, + repositoryId: z.string().min(1), + fullName: z.string().min(3), + defaultBranch: z.string().min(1).nullable(), +}; + +export const launchRepositoryReferenceSchema = z.strictObject({ + repository: z.discriminatedUnion('provider', [ + z.strictObject({ ...repositoryIdentityFields, provider: z.literal('github') }), + z.strictObject({ ...repositoryIdentityFields, provider: z.literal('gitlab') }), + z.strictObject({ + ...repositoryIdentityFields, + provider: z.literal('bitbucket'), + repositoryId: z.uuid(), + workspaceUuid: z.uuid(), + }), + ]), + authorization: z.strictObject({ + kind: z.literal('ownerIntegration'), + owner: z.discriminatedUnion('type', [ + z.strictObject({ type: z.literal('user'), id: z.string().min(1) }), + z.strictObject({ type: z.literal('org'), id: z.uuid() }), + ]), + integrationId: z.uuid(), + }), +}); + +export const listRepositoryBranchesInputSchema = launchRepositoryReferenceSchema.extend({ + cursor: z.string().min(1).max(4096).optional(), +}); + +export const listRepositoryBranchesOutputSchema = z.object({ + branches: z.array(z.object({ name: z.string().min(1), isDefault: z.boolean() })), + defaultBranch: z.string().min(1).nullable(), + nextCursor: z.string().nullable(), +}); + const PrepareSessionSharedFields = { // Repository source (mutually exclusive - must provide exactly one) githubRepo: z @@ -367,6 +403,9 @@ const PrepareSessionSharedFields = { .regex(/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/, 'Invalid repository format') .optional(), githubIntegrationId: z.uuid().optional(), + gitlabIntegrationId: z.uuid().optional(), + gitlabInstanceUrl: repositoryInstanceUrlSchema.optional(), + bitbucketIntegrationId: z.uuid().optional(), gitlabProject: z .string() .regex( @@ -456,6 +495,19 @@ export const basePrepareSessionNextSchema = z message: 'GitHub integration requires a GitHub repository', path: ['githubIntegrationId'], }) + .refine( + data => + (data.gitlabIntegrationId === undefined && data.gitlabInstanceUrl === undefined) || + data.gitlabProject !== undefined, + { + message: 'GitLab integration requires a GitLab project', + path: ['gitlabIntegrationId'], + } + ) + .refine(data => data.bitbucketIntegrationId === undefined || data.bitbucketRepo !== undefined, { + message: 'Bitbucket integration requires a Bitbucket repository', + path: ['bitbucketIntegrationId'], + }) .refine(hasOnlyOneAttachmentField, { message: 'Must not provide both attachments and images', path: ['attachments'], diff --git a/apps/web/src/routers/organizations/organization-cloud-agent-next-router.ts b/apps/web/src/routers/organizations/organization-cloud-agent-next-router.ts index 43e710f697..1db71afed4 100644 --- a/apps/web/src/routers/organizations/organization-cloud-agent-next-router.ts +++ b/apps/web/src/routers/organizations/organization-cloud-agent-next-router.ts @@ -13,7 +13,16 @@ import { organizationMemberProcedure, organizationMemberMutationProcedure, } from '@/routers/organizations/utils'; -import { fetchAllGitHubRepositoriesForOrganization } from '@/lib/cloud-agent/github-integration-helpers'; +import { + fetchAllGitHubRepositoriesForOrganization, + listGitHubRepositoryBranches, +} from '@/lib/cloud-agent/github-integration-helpers'; +import { + launchRepositoryReferenceSchema, + listRepositoryBranchesInputSchema, + listRepositoryBranchesOutputSchema, +} from '../cloud-agent-next-schemas'; +import { listBitbucketRepositoryBranches } from '@/lib/integrations/platforms/bitbucket/oauth-integration'; import { BitbucketOrganizationRepositoryListResultSchema, fetchBitbucketRepositoriesForOrganization, @@ -22,6 +31,7 @@ import { getGitLabInstanceUrlForOrganization, buildGitLabCloneUrl, fetchGitLabRepositoriesForOrganization, + listGitLabRepositoryBranches, } from '@/lib/cloud-agent/gitlab-integration-helpers'; import { orderRepositoriesByUsage } from '@/lib/cloud-agent/order-repositories'; import { @@ -197,6 +207,7 @@ const ListGitHubRepositoriesInput = z.object({ const ListGitLabRepositoriesInput = z.object({ organizationId: z.uuid(), forceRefresh: z.boolean().optional().default(false), + integrationId: z.uuid().optional(), }); const ListBitbucketRepositoriesInput = z.object({ @@ -248,6 +259,7 @@ export const organizationCloudAgentNextRouter = createTRPCRouter({ const { gitlabProject, + gitlabInstanceUrl, githubRepo, githubIntegrationId, bitbucketRepo, @@ -270,7 +282,11 @@ export const organizationCloudAgentNextRouter = createTRPCRouter({ }; if (gitlabProject) { - const instanceUrl = await getGitLabInstanceUrlForOrganization(organizationId); + const instanceUrl = await getGitLabInstanceUrlForOrganization( + organizationId, + input.gitlabIntegrationId, + gitlabInstanceUrl + ); const gitUrl = buildGitLabCloneUrl(gitlabProject, instanceUrl); gitParams = { gitUrl, platform: PLATFORM.GITLAB }; } else if (bitbucketRepo) { @@ -691,6 +707,69 @@ export const organizationCloudAgentNextRouter = createTRPCRouter({ ).getComputeBillingStatus(input.cloudAgentSessionId); }), + listRepositoryBranches: organizationMemberProcedure + .input(listRepositoryBranchesInputSchema.extend({ organizationId: z.uuid() })) + .output(listRepositoryBranchesOutputSchema) + .query(async ({ ctx, input }) => { + const owner = { type: 'org' as const, id: input.organizationId }; + if ( + input.authorization.owner.type !== owner.type || + input.authorization.owner.id !== owner.id + ) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Repository owner does not match' }); + } + if (input.repository.provider !== 'bitbucket') { + if (input.cursor) + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'This provider returns all branches', + }); + return input.repository.provider === 'github' + ? listGitHubRepositoryBranches(owner, input) + : listGitLabRepositoryBranches(owner, ctx.user.id, input); + } + const result = await fetchBitbucketRepositoriesForOrganization( + input.organizationId, + ctx.user.id + ); + if (result.status !== 'available') { + throw new TRPCError({ + code: result.status === 'temporarily_unavailable' ? 'SERVICE_UNAVAILABLE' : 'FORBIDDEN', + message: 'Bitbucket repositories are unavailable', + }); + } + const repository = input.repository; + const candidates = result.repositories.filter( + candidate => + candidate.id === repository.repositoryId && + candidate.fullName === repository.fullName && + candidate.workspaceUuid === repository.workspaceUuid + ); + // Old discovery producers omit identity. Do not invent a pin or use a second + // lookup; retain retryable compatibility until old clients/records and the 30-day window expire. + if (candidates.some(candidate => !candidate.repositoryReference)) { + throw new TRPCError({ + code: 'SERVICE_UNAVAILABLE', + message: 'Refresh Bitbucket repository identity', + }); + } + const selected = candidates.find( + candidate => + candidate.repositoryReference?.authorization.integrationId === + input.authorization.integrationId && + candidate.repositoryReference.repository.instanceUrl === repository.instanceUrl + ); + if (!selected?.repositoryReference) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Bitbucket repository not found' }); + } + return listBitbucketRepositoryBranches({ + actorUserId: ctx.user.id, + organizationId: input.organizationId, + reference: selected.repositoryReference, + cursor: input.cursor, + }); + }), + checkEligibility: organizationMemberProcedure .input(z.object({ organizationId: z.uuid() })) .query(async ({ ctx, input }) => { @@ -711,6 +790,8 @@ export const organizationCloudAgentNextRouter = createTRPCRouter({ name: z.string(), fullName: z.string(), private: z.boolean(), + instanceUrl: z.string().optional(), + repositoryReference: launchRepositoryReferenceSchema.optional(), defaultBranch: z.string().optional(), platformIntegrationId: z.string().uuid().optional(), platformAccountLogin: z.string().optional(), @@ -752,6 +833,10 @@ export const organizationCloudAgentNextRouter = createTRPCRouter({ name: z.string(), fullName: z.string(), private: z.boolean(), + instanceUrl: z.string().optional(), + repositoryReference: launchRepositoryReferenceSchema.optional(), + platformIntegrationId: z.uuid().optional(), + defaultBranch: z.string().optional(), }) ), integrationInstalled: z.boolean(), @@ -760,11 +845,18 @@ export const organizationCloudAgentNextRouter = createTRPCRouter({ }) ) .query(async ({ ctx, input }) => { - const result = await fetchGitLabRepositoriesForOrganization( - input.organizationId, - ctx.user.id, - input.forceRefresh - ); + const result = input.integrationId + ? await fetchGitLabRepositoriesForOrganization( + input.organizationId, + ctx.user.id, + input.forceRefresh, + input.integrationId + ) + : await fetchGitLabRepositoriesForOrganization( + input.organizationId, + ctx.user.id, + input.forceRefresh + ); return { repositories: await orderRepositoriesByUsage({ userId: ctx.user.id, diff --git a/apps/web/src/routers/provider-repository-contract.test.ts b/apps/web/src/routers/provider-repository-contract.test.ts new file mode 100644 index 0000000000..7c34579704 --- /dev/null +++ b/apps/web/src/routers/provider-repository-contract.test.ts @@ -0,0 +1,772 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { initTRPC, TRPCError } from '@trpc/server'; +import { z } from 'zod'; +import { PgDialect } from 'drizzle-orm/pg-core'; +import type { SQL } from 'drizzle-orm'; +import type { PlatformIntegration, User } from '@kilocode/db/schema'; +import type { + LaunchRepositoryReference, + Owner, +} from '@kilocode/app-shared/code-review/repository-identity'; +import type * as PersonalModule from './cloud-agent-next-router'; +import type * as OrganizationModule from './organizations/organization-cloud-agent-next-router'; +import type * as ClientModule from '@/lib/cloud-agent-next/cloud-agent-client'; +import type * as GitLabModule from '@/lib/integrations/gitlab-service'; +import type * as GitLabHelpers from '@/lib/cloud-agent/gitlab-integration-helpers'; +import type * as GitHubService from '@/lib/integrations/github-apps-service'; +import type * as TokenModule from '@/lib/integrations/platforms/bitbucket/token-service-client'; +import type * as OAuthModule from '@/lib/integrations/platforms/bitbucket/oauth-integration'; +import type * as WorkerSchemas from '../../../../services/cloud-agent-next/src/router/schemas'; + +const USER_ID = 'oauth/actor'; +const ORGANIZATION_ID = '11111111-1111-4111-8111-111111111111'; +const GITHUB_ID = '22222222-2222-4222-8222-222222222222'; +const GITLAB_ID = '33333333-3333-4333-8333-333333333333'; +const BITBUCKET_ID = '44444444-4444-4444-8444-444444444444'; +const WORKSPACE_UUID = '55555555-5555-4555-8555-555555555555'; +const REPOSITORY_UUID = '66666666-6666-4666-8666-666666666666'; +const INSTANCE_URL = 'https://gitlab.example.com/Enterprise'; +const userOwner = { type: 'user' as const, id: USER_ID }; +const orgOwner = { type: 'org' as const, id: ORGANIZATION_ID }; +const prepared = { + cloudAgentSessionId: 'agent_selected', + kiloSessionId: 'ses_12345678901234567890123456', +}; +const oldGitHub = '{"prompt":"Inspect","mode":"code","model":"test/model","githubRepo":"acme/API"}'; +const oldGitLab = + '{"prompt":"Inspect","mode":"code","model":"test/model","gitlabProject":"Group/Sub/API","upstreamBranch":"release/Case"}'; + +function integration(owner: Owner, platform: 'github' | 'gitlab'): PlatformIntegration { + return { + id: platform === 'github' ? GITHUB_ID : GITLAB_ID, + platform, + integration_status: 'active', + suspended_at: null, + auth_invalid_at: null, + owned_by_user_id: owner.type === 'user' ? owner.id : null, + owned_by_organization_id: owner.type === 'org' ? owner.id : null, + platform_installation_id: 'installation', + platform_account_login: 'acme', + github_app_type: 'standard', + repositories: [ + { + id: 42, + name: 'API', + full_name: platform === 'github' ? 'acme/API' : 'Group/Sub/API', + private: true, + default_branch: 'release/Case', + }, + ], + repositories_synced_at: '2026-08-29T00:00:00.000Z', + metadata: platform === 'gitlab' ? { gitlab_instance_url: INSTANCE_URL } : {}, + } as PlatformIntegration; +} +const mockTrpc = initTRPC.context<{ user: User }>().create(); +const queries: { sql: string; params: unknown[] }[] = []; +let mockGitLabRows: PlatformIntegration[] = []; +let bitbucketResult: OAuthModule.BitbucketOrganizationRepositoryListResult; +const mockDb = { + select: () => { + const query = { + from: () => query, + where: (condition: SQL) => { + queries.push(new PgDialect().sqlToQuery(condition)); + return query; + }, + limit: async () => mockGitLabRows, + }; + return query; + }, +}; +const mockGitLabBranches = + jest.fn< + ( + token: string, + project: string | number, + instance: string + ) => Promise<{ name: string; default: boolean; protected: boolean }[]> + >(); +const mockGitHubBranches = jest.fn<() => Promise<{ name: string; isDefault: boolean }[]>>(); +const mockGitLabCredential = + jest.fn< + () => Promise< + | { status: 'available'; token: string; instanceUrl: string } + | { status: 'temporarily_unavailable' } + > + >(); + +jest.mock('@/lib/trpc/init', () => ({ + createTRPCRouter: mockTrpc.router, + baseProcedure: mockTrpc.procedure, +})); +jest.mock('@/routers/organizations/utils', () => { + const procedure = mockTrpc.procedure + .input(z.object({ organizationId: z.uuid() })) + .use(({ input, next }) => { + if (input.organizationId !== ORGANIZATION_ID) + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'You do not have access to this organization', + }); + return next(); + }); + return { organizationMemberProcedure: procedure, organizationMemberMutationProcedure: procedure }; +}); +jest.mock('@/lib/drizzle', () => ({ db: mockDb })); +jest.mock('@/lib/config.server', () => ({ + INTERNAL_API_SECRET: 'test-internal', + GIT_TOKEN_SERVICE_API_URL: 'https://token-service.test', +})); +jest.mock('@/lib/dotenvx', () => ({ getEnvVariable: () => 'https://worker.test' })); +jest.mock('@/lib/tokens', () => ({ generateCloudAgentToken: () => 'test-token' })); +jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn() })); +jest.mock('@/lib/cloud-agent-next/balance-check-eligibility', () => ({ + computeCloudAgentNextBalanceCheckEligibility: async () => ({ + isFree: false, + hasUserByokAvailable: false, + }), +})); +jest.mock('@/lib/posthog-feature-flags', () => ({ + isFeatureFlagEnabledOrDevelopment: async () => false, +})); +jest.mock('@/lib/user/balance', () => ({})); +jest.mock('@/lib/organizations/organization-usage', () => ({})); +jest.mock('@/lib/organizations/effective-model-access.server', () => ({})); +jest.mock('@/lib/r2/cloud-agent-attachments', () => ({})); +jest.mock('@/lib/r2/cloud-agent-pending-uploads', () => ({})); +jest.mock('@/lib/cloud-agent/session-ownership', () => ({})); +jest.mock('@/lib/cloud-agent/stream-ticket', () => ({})); +jest.mock('@/lib/cloud-agent/order-repositories', () => ({ + orderRepositoriesByUsage: async ({ repositories }: { repositories: unknown[] }) => repositories, +})); +jest.mock('@/lib/agent-config/db/agent-configs', () => ({})); +jest.mock('@/lib/utils.server', () => ({ logExceptInTest: jest.fn() })); +jest.mock('@/lib/integrations/platforms/gitlab/credential-encryption', () => ({})); +jest.mock('@/lib/integrations/platforms/gitlab/credential-broker-client', () => ({ + fetchGitLabCredential: mockGitLabCredential, +})); +jest.mock('@/components/cloud-agent/demo-config', () => ({ + DEMO_SOURCE_OWNER: 'demo', + DEMO_SOURCE_REPO_NAME: 'demo', +})); +jest.mock('@/lib/integrations/db/platform-integrations', () => ({ + getIntegrationForOwner: async (owner: Owner) => integration(owner, 'github'), + getPrimaryGitHubIntegrationForOrganization: async (id: string) => + integration({ type: 'org', id }, 'github'), + getIntegrationsByOrganization: async (id: string) => [integration({ type: 'org', id }, 'github')], + getGitHubIntegrationById: async (owner: Owner, id: string) => + id === GITHUB_ID ? integration(owner, 'github') : null, + updateRepositoriesForIntegration: async () => undefined, +})); +jest.mock('@/lib/integrations/platforms/github/adapter', () => ({ + fetchGitHubBranches: mockGitHubBranches, +})); +jest.mock('@/lib/integrations/platforms/gitlab/adapter', () => ({ + fetchGitLabBranches: mockGitLabBranches, +})); +jest.mock('@/lib/cloud-agent/bitbucket-integration-helpers', () => ({ + BitbucketOrganizationRepositoryListResultSchema: jest.requireActual( + '@/lib/integrations/platforms/bitbucket/oauth-integration' + ).BitbucketOrganizationRepositoryListResultSchema, + fetchBitbucketRepositoriesForOrganization: async () => bitbucketResult, +})); +jest.mock('@/lib/integrations/platforms/bitbucket/interactive-client', () => ({ + BitbucketInteractiveClientError: class extends Error {}, + createBitbucketInteractiveClient: () => ({ + execute: async () => ({ + status: 200, + data: { values: [{ name: 'release/Case' }] }, + metadata: { + actorUserId: USER_ID, + organizationId: ORGANIZATION_ID, + integrationId: BITBUCKET_ID, + }, + }), + }), +})); +jest.mock('next/server', () => ({ after: jest.fn() })); +jest.mock( + '@/lib/integrations/platforms/bitbucket/workspace-access-token-organization-authorization', + () => ({}) +); + +let personal: ReturnType; +let organization: ReturnType< + typeof OrganizationModule.organizationCloudAgentNextRouter.createCaller +>; +let client: typeof ClientModule; +let gitlab: typeof GitLabModule; +let gitlabHelpers: typeof GitLabHelpers; +let githubService: typeof GitHubService; +let token: typeof TokenModule; +let workerSchemas: typeof WorkerSchemas; +const sent: Record[] = []; +const originalFetch = global.fetch; +beforeAll(async () => { + const context = { user: { id: USER_ID, is_admin: false } as User }; + personal = (await import('./cloud-agent-next-router')).cloudAgentNextRouter.createCaller(context); + organization = ( + await import('./organizations/organization-cloud-agent-next-router') + ).organizationCloudAgentNextRouter.createCaller(context); + client = await import('@/lib/cloud-agent-next/cloud-agent-client'); + gitlab = await import('@/lib/integrations/gitlab-service'); + gitlabHelpers = await import('@/lib/cloud-agent/gitlab-integration-helpers'); + githubService = await import('@/lib/integrations/github-apps-service'); + token = await import('@/lib/integrations/platforms/bitbucket/token-service-client'); + workerSchemas = await import('../../../../services/cloud-agent-next/src/router/schemas'); +}); +beforeEach(() => { + sent.length = 0; + queries.length = 0; + mockGitLabRows = [integration(userOwner, 'gitlab')]; + mockGitLabCredential.mockResolvedValue({ + status: 'available', + token: 'provider-test', + instanceUrl: INSTANCE_URL, + }); + mockGitLabBranches.mockResolvedValue([{ name: 'release/Case', default: true, protected: false }]); + mockGitHubBranches.mockResolvedValue([{ name: 'release/Case', isDefault: true }]); + bitbucketResult = { + status: 'available', + repositories: [ + token.withBitbucketRepositoryIdentity( + { + id: REPOSITORY_UUID, + workspaceUuid: WORKSPACE_UUID, + name: 'API', + fullName: 'acme/API', + private: true, + defaultBranch: 'release/Case', + }, + orgOwner, + BITBUCKET_ID + ), + ], + syncedAt: '2026-08-29T00:00:00.000Z', + }; + global.fetch = jest.fn(async (_url, init) => { + if (typeof init?.body !== 'string') throw new Error('Expected a serialized prepare input'); + sent.push(JSON.parse(init.body)); + return Response.json({ result: { data: prepared } }); + }); + jest.spyOn(console, 'log').mockImplementation(() => undefined); +}); +afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); +}); + +function reference(provider: 'github' | 'gitlab', owner: Owner): LaunchRepositoryReference { + return { + repository: { + provider, + repositoryId: '42', + instanceUrl: provider === 'github' ? 'https://github.com' : INSTANCE_URL, + fullName: provider === 'github' ? 'acme/API' : 'Group/Sub/API', + defaultBranch: 'release/Case', + }, + authorization: { + kind: 'ownerIntegration', + owner, + integrationId: provider === 'github' ? GITHUB_ID : GITLAB_ID, + }, + }; +} + +describe('external caller compatibility matrix', () => { + const callers = [ + ['NewSessionPanel', oldGitHub], + ['CloudAgentProvider', oldGitLab], + ['extension agents-new-session', oldGitLab], + ['createExtensionAgentSessionManager', oldGitHub], + ['review-ID fork route', oldGitHub], + ['review-Markdown fork route', oldGitLab], + ['old installed mobile clients', oldGitHub], + ] as const; + it.each(callers)( + 'accepts the serialized old %s input through both prepare procedures', + async (_name, serialized) => { + const input = JSON.parse(serialized); + await expect(personal.prepareSession(input)).resolves.toEqual(prepared); + mockGitLabRows = [integration(orgOwner, 'gitlab')]; + await expect( + organization.prepareSession({ ...input, organizationId: ORGANIZATION_ID }) + ).resolves.toEqual(prepared); + expect(sent).toHaveLength(2); + for (const request of sent) { + expect(request).toMatchObject({ + prompt: 'Inspect', + model: 'test/model', + mode: 'code', + createdOnPlatform: 'cloud-agent-web', + }); + expect(request).not.toHaveProperty('gitlabIntegrationId'); + expect(request).not.toHaveProperty('bitbucketIntegrationId'); + if (serialized === oldGitLab) + expect(request).toMatchObject({ + gitUrl: 'https://gitlab.example.com/Enterprise/Group/Sub/API.git', + upstreamBranch: 'release/Case', + platform: 'gitlab', + }); + else { + expect(request).toMatchObject({ githubRepo: 'acme/API', platform: 'github' }); + expect(request).not.toHaveProperty('upstreamBranch'); + } + } + expect(sent[0]).not.toHaveProperty('kilocodeOrganizationId'); + expect(sent[1].kilocodeOrganizationId).toBe(ORGANIZATION_ID); + } + ); + + it.each([ + [ + 'spawn-cloud-agent-session', + '{"prompt":"Inspect","mode":"code","model":"test/model","githubRepo":"acme/API","createdOnPlatform":"slack","callbackTarget":{"url":"https://app.test/callback"}}', + ], + [ + 'App Builder', + '{"prompt":"Build","mode":"build","model":"test/model","gitUrl":"https://builder.test/project.git","upstreamBranch":"main","autoCommit":true,"setupCommands":["bun install"],"createdOnPlatform":"app-builder"}', + ], + [ + 'startSecurityAnalysis', + '{"prompt":"Analyze","mode":"code","model":"test/model","githubRepo":"acme/API","createdOnPlatform":"security-agent","callbackTarget":{"url":"https://app.test/api/internal/security-analysis-callback/finding"}}', + ], + [ + 'runSessionToCompletion', + '{"prompt":"Inspect","mode":"code","model":"test/model","gitUrl":"https://gitlab.example.com/Enterprise/Group/Sub/API.git","platform":"gitlab","upstreamBranch":"release/Case"}', + ], + ['CloudAgentNextClient', oldGitHub], + ] as const)( + 'retains the old %s payload in the web client transport', + async (_name, serialized) => { + const input = JSON.parse(serialized); + await expect( + new client.CloudAgentNextClient('test-token').prepareSession(input) + ).resolves.toEqual(prepared); + expect(sent).toEqual([input]); + expect(workerSchemas.PrepareSessionInput.parse(sent[0])).toMatchObject(input); + } + ); + + it.each(['NewDeploymentDialog', 'RepoProfileBindingsDialog', 'ProfilesListDialog'])( + 'preserves old discovery fields consumed by %s', + async () => { + const github = await personal.listGitHubRepositories({ forceRefresh: false }); + const gitlabResult = await personal.listGitLabRepositories({ forceRefresh: false }); + expect(github.repositories[0]).toMatchObject({ + id: 42, + name: 'API', + fullName: 'acme/API', + private: true, + }); + expect(gitlabResult.repositories[0]).toMatchObject({ + id: 42, + name: 'API', + fullName: 'Group/Sub/API', + private: true, + }); + expect(github.repositories[0].repositoryReference?.authorization).toEqual({ + kind: 'ownerIntegration', + owner: userOwner, + integrationId: GITHUB_ID, + }); + expect(gitlabResult.repositories[0].repositoryReference?.repository).toMatchObject({ + instanceUrl: INSTANCE_URL, + defaultBranch: 'release/Case', + }); + } + ); + + it('keeps the old deployment branch helper input and output', async () => { + mockGitLabRows = [integration(orgOwner, 'github')]; + await expect(githubService.listBranches(orgOwner, GITHUB_ID, 'acme/API')).resolves.toEqual({ + branches: [{ name: 'release/Case', isDefault: true }], + }); + }); + + it.each([ + { type: 'github', repo: 'acme/API', branch: 'release/Case' }, + { + type: 'gitlab', + url: 'https://gitlab.example.com/Enterprise/Group/Sub/API.git', + branch: 'release/Case', + }, + { + type: 'bitbucket', + url: 'https://bitbucket.org/acme/API.git', + workspaceUuid: WORKSPACE_UUID, + repositoryUuid: REPOSITORY_UUID, + branch: 'release/Case', + }, + ])('keeps serialized old grouped Worker $type inputs without requiring pins', repository => { + const input = JSON.parse( + JSON.stringify({ + message: { prompt: 'Inspect' }, + agent: { mode: 'code', model: 'test/model' }, + repository, + }) + ); + expect(workerSchemas.StartSessionInput.parse(input)).toEqual(input); + }); + + it('retains old clone-only payloads without a synthetic prompt', async () => { + const input = JSON.parse( + '{"githubRepo":"acme/API","mode":"code","model":"test/model","cloneFromKiloSessionId":"ses_12345678901234567890123456","autoInitiate":true,"operationKey":"77777777-7777-4777-8777-777777777777"}' + ); + await personal.prepareSession(input); + expect(sent[0]).toMatchObject({ + cloneFromKiloSessionId: 'ses_12345678901234567890123456', + operationKey: '77777777-7777-4777-8777-777777777777', + autoInitiate: true, + }); + expect(sent[0]).not.toHaveProperty('prompt'); + }); +}); + +describe('provider pins and exact prepare branch', () => { + it('accepts a serialized old organization Bitbucket payload without an integration pin', async () => { + const input = JSON.parse( + '{"organizationId":"11111111-1111-4111-8111-111111111111","prompt":"Inspect","mode":"code","model":"test/model","bitbucketRepo":{"fullName":"acme/API","workspaceUuid":"55555555-5555-4555-8555-555555555555","repositoryUuid":"66666666-6666-4666-8666-666666666666"}}' + ); + await organization.prepareSession(input); + expect(sent[0]).toMatchObject({ + gitUrl: 'https://bitbucket.org/acme/API.git', + platform: 'bitbucket', + bitbucketWorkspaceUuid: WORKSPACE_UUID, + bitbucketRepositoryUuid: REPOSITORY_UUID, + }); + expect(sent[0]).not.toHaveProperty('bitbucketIntegrationId'); + }); + + it.each([ + { githubRepo: 'acme/API', gitlabIntegrationId: GITLAB_ID }, + { githubRepo: 'acme/API', gitlabInstanceUrl: INSTANCE_URL }, + { githubRepo: 'acme/API', bitbucketIntegrationId: BITBUCKET_ID }, + { gitlabProject: 'Group/Sub/API', githubIntegrationId: GITHUB_ID }, + ])('rejects cross-provider pins: %j', async repository => { + await expect( + personal.prepareSession({ + prompt: 'Inspect', + mode: 'code', + model: 'test/model', + ...repository, + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(sent).toEqual([]); + }); + + it.each([ + 'https://user:password@gitlab.example.com/Enterprise', + 'https://gitlab.example.com/Enterprise?token=secret', + 'https://gitlab.example.com/Enterprise#other', + ])('rejects an invalid instance pin as a non-retryable input: %s', async instanceUrl => { + await expect( + personal.prepareSession({ + prompt: 'Inspect', + mode: 'code', + model: 'test/model', + gitlabProject: 'Group/Sub/API', + gitlabIntegrationId: GITLAB_ID, + gitlabInstanceUrl: instanceUrl, + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(sent).toEqual([]); + }); + + it('retains legacy clone-host lookup behavior while denying a pinned suspended integration', async () => { + mockGitLabRows = [{ ...integration(userOwner, 'gitlab'), integration_status: 'suspended' }]; + await expect(gitlabHelpers.getGitLabInstanceUrlForUser(USER_ID)).resolves.toBe(INSTANCE_URL); + await expect( + gitlabHelpers.getGitLabInstanceUrlForUser(USER_ID, GITLAB_ID) + ).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }); + }); + + it.each(['personal', 'organization'] as const)( + 'forwards the GitHub pin and exact branch through %s prepare', + async scope => { + const input = { + prompt: 'Inspect', + model: 'test/model', + mode: 'code', + githubRepo: 'acme/API', + githubIntegrationId: GITHUB_ID, + upstreamBranch: 'feature/Case-sensitive', + }; + await (scope === 'personal' + ? personal.prepareSession(input) + : organization.prepareSession({ ...input, organizationId: ORGANIZATION_ID })); + expect(sent[0]).toMatchObject({ + githubRepo: 'acme/API', + githubIntegrationId: GITHUB_ID, + upstreamBranch: 'feature/Case-sensitive', + }); + } + ); + it.each(['personal', 'organization'] as const)( + 'forwards the GitLab pin, authorized host, and exact branch through %s prepare', + async scope => { + mockGitLabRows = [integration(scope === 'personal' ? userOwner : orgOwner, 'gitlab')]; + const input = { + prompt: 'Inspect', + model: 'test/model', + mode: 'code', + gitlabProject: 'Group/Sub/API', + gitlabIntegrationId: GITLAB_ID, + gitlabInstanceUrl: INSTANCE_URL, + upstreamBranch: 'feature/Case-sensitive', + }; + await (scope === 'personal' + ? personal.prepareSession(input) + : organization.prepareSession({ ...input, organizationId: ORGANIZATION_ID })); + expect(workerSchemas.PrepareSessionInput.parse(sent[0])).toMatchObject({ + gitUrl: 'https://gitlab.example.com/Enterprise/Group/Sub/API.git', + gitlabIntegrationId: GITLAB_ID, + upstreamBranch: 'feature/Case-sensitive', + platform: 'gitlab', + }); + // The Worker derives the instance from gitUrl, not an unsupported flat pin. + expect(sent[0]).not.toHaveProperty('gitlabInstanceUrl'); + expect(queries[0].params).toEqual([ + scope === 'personal' ? USER_ID : ORGANIZATION_ID, + 'gitlab', + GITLAB_ID, + ]); + expect(queries[0].sql).toContain( + scope === 'personal' ? '"owned_by_organization_id" is null' : '"owned_by_user_id" is null' + ); + } + ); + it('forwards the Bitbucket pin and branch through organization prepare', async () => { + await organization.prepareSession({ + organizationId: ORGANIZATION_ID, + prompt: 'Inspect', + mode: 'code', + model: 'test/model', + bitbucketRepo: { + fullName: 'acme/API', + workspaceUuid: WORKSPACE_UUID, + repositoryUuid: REPOSITORY_UUID, + }, + bitbucketIntegrationId: BITBUCKET_ID, + upstreamBranch: 'feature/Case-sensitive', + }); + expect(sent[0]).toMatchObject({ + gitUrl: 'https://bitbucket.org/acme/API.git', + platform: 'bitbucket', + bitbucketIntegrationId: BITBUCKET_ID, + bitbucketWorkspaceUuid: WORKSPACE_UUID, + bitbucketRepositoryUuid: REPOSITORY_UUID, + upstreamBranch: 'feature/Case-sensitive', + }); + }); + it('rejects Personal Bitbucket discovery and prepare without a Worker request', async () => { + await expect(personal.listBitbucketRepositories()).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + await expect( + personal.prepareSession({ + prompt: 'Inspect', + mode: 'code', + model: 'test/model', + bitbucketRepo: { + fullName: 'acme/API', + workspaceUuid: WORKSPACE_UUID, + repositoryUuid: REPOSITORY_UUID, + }, + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(sent).toEqual([]); + }); + it.each(['personal', 'organization'] as const)( + 'does not substitute a default when strict checkout rejects a %s branch', + async scope => { + global.fetch = jest.fn(async (_url, init) => { + if (typeof init?.body !== 'string') throw new Error('Expected JSON'); + sent.push(JSON.parse(init.body)); + return Response.json( + { + error: { + message: 'Upstream branch not found', + code: -32004, + data: { code: 'NOT_FOUND', httpStatus: 404 }, + }, + }, + { status: 404 } + ); + }); + const input = { + prompt: 'Inspect', + mode: 'code', + model: 'test/model', + githubRepo: 'acme/API', + upstreamBranch: 'missing/strict', + }; + await expect( + scope === 'personal' + ? personal.prepareSession(input) + : organization.prepareSession({ ...input, organizationId: ORGANIZATION_ID }) + ).rejects.toThrow('Upstream branch not found'); + expect(sent).toHaveLength(1); + expect(sent[0].upstreamBranch).toBe('missing/strict'); + } + ); + it('retains retryable web-client error metadata', async () => { + global.fetch = jest.fn().mockResolvedValue( + Response.json( + { + error: { + message: 'Provider unavailable', + code: -32603, + data: { code: 'SERVICE_UNAVAILABLE', httpStatus: 503 }, + }, + }, + { status: 503 } + ) + ); + await expect( + new client.CloudAgentNextClient('test-token').prepareSession(JSON.parse(oldGitHub)) + ).rejects.toMatchObject({ + message: 'Provider unavailable', + data: { code: 'SERVICE_UNAVAILABLE', httpStatus: 503 }, + }); + }); + it('rejects a stale GitLab pin instead of sending an unpinned prepare', async () => { + mockGitLabRows = []; + await expect( + personal.prepareSession({ + prompt: 'Inspect', + mode: 'code', + model: 'test/model', + gitlabProject: 'Group/Sub/API', + gitlabIntegrationId: GITLAB_ID, + }) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + expect(sent).toEqual([]); + }); + it('rejects an ambiguous legacy GitLab lookup', async () => { + mockGitLabRows = [ + integration(userOwner, 'gitlab'), + { + ...integration(userOwner, 'gitlab'), + id: GITHUB_ID, + metadata: { gitlab_instance_url: 'https://other.example.com' }, + }, + ]; + await expect(gitlabHelpers.getGitLabInstanceUrlForUser(USER_ID)).rejects.toMatchObject({ + code: 'CONFLICT', + }); + }); + it('retains an unambiguous absent-selector clone host', async () => { + await expect(gitlabHelpers.getGitLabInstanceUrlForUser(USER_ID)).resolves.toBe(INSTANCE_URL); + expect(queries[0].params).toEqual([USER_ID, 'gitlab']); + }); +}); + +describe('mobile-exposed branch consumer boundaries', () => { + it.each(['github', 'gitlab'] as const)( + 'exposes exact %s branches in both owner contexts', + async provider => { + if (provider === 'gitlab') + mockGitLabBranches.mockImplementation(async (_token, projectId, instanceUrl) => { + if (projectId !== '42' || instanceUrl !== INSTANCE_URL) + throw new Error('Wrong GitLab project identity'); + return [ + { name: 'feature/Case', default: false, protected: false }, + { name: 'release/Case', default: true, protected: true }, + ]; + }); + const personalResult = await personal.listRepositoryBranches(reference(provider, userOwner)); + mockGitLabRows = [integration(orgOwner, 'gitlab')]; + const organizationResult = await organization.listRepositoryBranches({ + ...reference(provider, orgOwner), + organizationId: ORGANIZATION_ID, + }); + expect(personalResult).toEqual(organizationResult); + expect(personalResult).toMatchObject({ defaultBranch: 'release/Case', nextCursor: null }); + } + ); + it('returns zero GitLab branches distinctly from a failure or guessed default', async () => { + mockGitLabBranches.mockResolvedValue([]); + await expect(personal.listRepositoryBranches(reference('gitlab', userOwner))).resolves.toEqual({ + branches: [], + defaultBranch: null, + nextCursor: null, + }); + }); + it('preserves a retryable GitLab credential failure', async () => { + mockGitLabCredential.mockResolvedValue({ status: 'temporarily_unavailable' }); + await expect( + personal.listRepositoryBranches(reference('gitlab', userOwner)) + ).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE' }); + }); + it('preserves a GitLab branch page failure rather than returning an empty success', async () => { + mockGitLabBranches.mockRejectedValue(new Error('GitLab page 2 failed')); + await expect(personal.listRepositoryBranches(reference('gitlab', userOwner))).rejects.toThrow( + 'GitLab page 2 failed' + ); + }); + it.each(['owner', 'repository', 'instance'] as const)( + 'rejects a changed GitLab %s without same-name substitution', + async changed => { + const input = reference('gitlab', userOwner); + if (changed === 'owner') input.authorization.owner = orgOwner; + if (changed === 'repository') input.repository.repositoryId = '999'; + if (changed === 'instance') input.repository.instanceUrl = 'https://other.example.com'; + await expect(personal.listRepositoryBranches(input)).rejects.toMatchObject({ + code: + changed === 'owner' + ? 'FORBIDDEN' + : changed === 'repository' + ? 'NOT_FOUND' + : 'PRECONDITION_FAILED', + }); + } + ); + it('keeps the old GitLab branch service result shape', async () => { + await expect( + gitlab.listGitLabBranches(userOwner, GITLAB_ID, { userId: USER_ID }, 'Group/Sub/API') + ).resolves.toEqual({ branches: [{ name: 'release/Case', isDefault: true }] }); + }); + it('uses the selected Bitbucket cache default, not the client default', async () => { + if (bitbucketResult.status !== 'available') throw new Error('Expected available fixture'); + const selected = bitbucketResult.repositories[0].repositoryReference!; + await expect( + organization.listRepositoryBranches({ + ...selected, + repository: { ...selected.repository, defaultBranch: 'guessed/main' }, + organizationId: ORGANIZATION_ID, + }) + ).resolves.toEqual({ + branches: [{ name: 'release/Case', isDefault: true }], + defaultBranch: 'release/Case', + nextCursor: null, + }); + }); + it('rejects an old Bitbucket pin after replacement produces the same repository name', async () => { + if (bitbucketResult.status !== 'available') throw new Error('Expected available fixture'); + const selected = bitbucketResult.repositories[0].repositoryReference!; + await expect( + organization.listRepositoryBranches({ + ...selected, + authorization: { ...selected.authorization, integrationId: GITHUB_ID }, + organizationId: ORGANIZATION_ID, + }) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + it('does not invent integration identity for an old Bitbucket producer', async () => { + if (bitbucketResult.status !== 'available') throw new Error('Expected available fixture'); + const selected = bitbucketResult.repositories[0].repositoryReference!; + bitbucketResult.repositories = [ + { + id: REPOSITORY_UUID, + workspaceUuid: WORKSPACE_UUID, + name: 'API', + fullName: 'acme/API', + private: true, + }, + ]; + await expect( + organization.listRepositoryBranches({ ...selected, organizationId: ORGANIZATION_ID }) + ).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE' }); + }); +}); diff --git a/packages/worker-utils/src/cloud-agent-next-client.test.ts b/packages/worker-utils/src/cloud-agent-next-client.test.ts index ea3d8b997b..6b33f7393d 100644 --- a/packages/worker-utils/src/cloud-agent-next-client.test.ts +++ b/packages/worker-utils/src/cloud-agent-next-client.test.ts @@ -58,6 +58,83 @@ describe('CloudAgentNextFetchClient prepareSession', () => { }); }); +describe('provider prepare compatibility', () => { + const cases = [ + { + platform: 'github' as const, + githubRepo: 'acme/API', + githubIntegrationId: '11111111-1111-4111-8111-111111111111', + }, + { + platform: 'gitlab' as const, + gitUrl: 'https://gitlab.example.com/base/Group/API.git', + gitlabIntegrationId: '22222222-2222-4222-8222-222222222222', + }, + { + platform: 'bitbucket' as const, + gitUrl: 'https://bitbucket.org/acme/API.git', + bitbucketIntegrationId: '33333333-3333-4333-8333-333333333333', + bitbucketWorkspaceUuid: '44444444-4444-4444-8444-444444444444', + bitbucketRepositoryUuid: '55555555-5555-4555-8555-555555555555', + }, + ]; + it.each(cases)( + 'sends the exact $platform pin and selected branch on the wire', + async repository => { + let received: unknown; + vi.stubGlobal( + 'fetch', + vi.fn(async (_url, init) => { + if (typeof init?.body !== 'string') throw new Error('Expected a JSON request body'); + received = JSON.parse(init.body); + return Response.json({ + result: { + data: { cloudAgentSessionId: 'agent_selected', kiloSessionId: 'ses_selected' }, + }, + }); + }) + ); + const input: CloudAgentPrepareSessionInput = { + prompt: 'Inspect', + mode: 'code', + model: 'test', + ...repository, + upstreamBranch: 'feature/Case-sensitive', + }; + const result = await createCloudAgentNextFetchClient(BASE_URL).prepareSession({}, input); + expect(received).toEqual(input); + expect(result).toEqual({ + cloudAgentSessionId: 'agent_selected', + kiloSessionId: 'ses_selected', + }); + } + ); + + it.each([ + 'Code Review', + 'Auto Triage', + 'Auto Fix', + 'Auto Fix prepare adapter', + 'createCloudAgentNextFetchClient', + ])('retains a serialized old %s payload without adding pins', async () => { + const serialized = + '{"prompt":"Inspect","mode":"code","model":"test","githubRepo":"acme/API","upstreamBranch":"release/old"}'; + let received = ''; + vi.stubGlobal( + 'fetch', + vi.fn(async (_url, init) => { + if (typeof init?.body !== 'string') throw new Error('Expected a JSON request body'); + received = init.body; + return Response.json({ + result: { data: { cloudAgentSessionId: 'agent_old', kiloSessionId: 'ses_old' } }, + }); + }) + ); + await createCloudAgentNextFetchClient(BASE_URL).prepareSession({}, JSON.parse(serialized)); + expect(received).toBe(serialized); + }); +}); + describe('CloudAgentNextFetchClient billing error detection', () => { it('recognizes every exported billing body pattern', () => { for (const pattern of CLOUD_AGENT_NEXT_BILLING_ERROR_PATTERNS) { diff --git a/packages/worker-utils/src/cloud-agent-next-client.ts b/packages/worker-utils/src/cloud-agent-next-client.ts index a85e74ea0f..57f156a74b 100644 --- a/packages/worker-utils/src/cloud-agent-next-client.ts +++ b/packages/worker-utils/src/cloud-agent-next-client.ts @@ -41,7 +41,9 @@ export type CloudAgentPrepareSessionInput = { model: string; variant?: string; githubRepo?: string; + githubIntegrationId?: string; githubToken?: string; + gitlabIntegrationId?: string; gitUrl?: string; gitToken?: string; platform?: 'github' | 'gitlab' | 'bitbucket'; From d8d1ca32a94df193cebfcca0aa646ff0e3bbcfab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 31 Aug 2026 10:30:44 +0200 Subject: [PATCH 3/3] fix(provider-review): use typed Bitbucket branch pagination --- .../bitbucket/oauth-integration.test.ts | 31 +++++++++++++------ .../platforms/bitbucket/oauth-integration.ts | 1 - 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/apps/web/src/lib/integrations/platforms/bitbucket/oauth-integration.test.ts b/apps/web/src/lib/integrations/platforms/bitbucket/oauth-integration.test.ts index 80eb19efb4..9a2bcbc351 100644 --- a/apps/web/src/lib/integrations/platforms/bitbucket/oauth-integration.test.ts +++ b/apps/web/src/lib/integrations/platforms/bitbucket/oauth-integration.test.ts @@ -389,16 +389,15 @@ function branchesInput(defaultBranch: string | undefined = 'release/Case') { describe('Bitbucket branch boundary through the b1/a3 transport', () => { const next = 'https://api.bitbucket.org/2.0/repositories/acme/API/refs/branches?pagelen=50&page=2'; - it('uses validated pagination and the selected repository default', async () => { - useInteractiveService( - jest.fn().mockResolvedValue( - Response.json({ - values: [{ name: 'release/Case' }, { name: 'feature/Case' }], - pagelen: 50, - next, - }) - ) + it('uses the supported request with transport pagination and the selected default', async () => { + const providerFetch = jest.fn().mockResolvedValue( + Response.json({ + values: [{ name: 'release/Case' }, { name: 'feature/Case' }], + pagelen: 50, + next, + }) ); + useInteractiveService(providerFetch); await expect(oauth.listBitbucketRepositoryBranches(branchesInput())).resolves.toEqual({ branches: [ { name: 'release/Case', isDefault: true }, @@ -407,6 +406,20 @@ describe('Bitbucket branch boundary through the b1/a3 transport', () => { defaultBranch: 'release/Case', nextCursor: next, }); + expect(JSON.parse(String(jest.mocked(global.fetch).mock.calls[0][1]?.body))).toEqual({ + integrationId: INTEGRATION_ID, + workspaceUuid: WORKSPACE_UUID, + workspaceSlug: 'acme', + repositoryUuid: REPOSITORY_UUID, + repositoryFullName: 'acme/API', + request: { + operation: 'branches', + params: { path: { workspace: 'acme', repo_slug: 'API' } }, + }, + }); + expect(providerFetch.mock.calls[0][0]).toBe( + 'https://api.bitbucket.org/2.0/repositories/acme/API/refs/branches?pagelen=50' + ); }); it('preserves the first page while a failed page can be retried', async () => { let pageCalls = 0; diff --git a/apps/web/src/lib/integrations/platforms/bitbucket/oauth-integration.ts b/apps/web/src/lib/integrations/platforms/bitbucket/oauth-integration.ts index a37cd8687d..b30fe5e6c7 100644 --- a/apps/web/src/lib/integrations/platforms/bitbucket/oauth-integration.ts +++ b/apps/web/src/lib/integrations/platforms/bitbucket/oauth-integration.ts @@ -313,7 +313,6 @@ export async function listBitbucketRepositoryBranches(input: { operation: 'branches', params: { path: { workspace: workspaceSlug, repo_slug: repositorySlug }, - query: { pagelen: 50 }, }, ...(input.cursor ? { next: input.cursor } : {}), });