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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 52 additions & 23 deletions apps/web/src/lib/cloud-agent/bitbucket-integration-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '@/lib/drizzle';
import { PLATFORM } from '@/lib/integrations/core/constants';
import {
withRepositoryReadDeadline,
type RepositoryReadOptions,
} from '@/lib/integrations/core/repository-read-limits';
import {
BitbucketOrganizationRepositoryListResultSchema,
type BitbucketOrganizationRepositoryListResult,
Expand All @@ -15,7 +19,7 @@ import {
} from '@/lib/integrations/platforms/bitbucket/workspace-access-token-repository-cache';
import { platform_integrations } from '@kilocode/db/schema';

async function findBitbucketIntegrationType(organizationId: string) {
async function findBitbucketIntegration(organizationId: string) {
const [integration] = await db
.select({ integrationType: platform_integrations.integration_type })
.from(platform_integrations)
Expand All @@ -27,38 +31,63 @@ async function findBitbucketIntegrationType(organizationId: string) {
)
)
.limit(1);
return integration?.integrationType ?? null;
return integration ?? null;
}

export async function fetchBitbucketRepositoriesForOrganization(
organizationId: string,
kiloUserId: string,
forceRefresh = false
forceRefresh = false,
options?: RepositoryReadOptions
): Promise<BitbucketOrganizationRepositoryListResult> {
const canonicalOrganizationId = z.uuid().safeParse(organizationId);
if (!canonicalOrganizationId.success) return { status: 'invalid_request' };

const integrationType = await findBitbucketIntegrationType(canonicalOrganizationId.data);
if (integrationType === 'workspace_access_token') {
if (forceRefresh) {
return refreshBitbucketWorkspaceAccessTokenRepositoriesForMember({
organizationId: canonicalOrganizationId.data,
kiloUserId,
});
}
return readCachedBitbucketWorkspaceAccessTokenRepositories({
organizationId: canonicalOrganizationId.data,
});
}
if (integrationType === 'oauth') {
return listBitbucketRepositories({
owner: { type: 'org', id: canonicalOrganizationId.data },
kiloUserId,
forceRefresh,
});
let integrationFound = false;
try {
const result = await withRepositoryReadDeadline<BitbucketOrganizationRepositoryListResult>(
options,
async signal => {
const readOptions = options?.bounded ? { bounded: true, signal } : undefined;
const integration = await findBitbucketIntegration(canonicalOrganizationId.data);
signal?.throwIfAborted();
if (!integration) return { status: 'not_connected' };
integrationFound = true;
const integrationType = integration.integrationType;
if (integrationType === 'workspace_access_token') {
if (!forceRefresh) {
const cached = await readCachedBitbucketWorkspaceAccessTokenRepositories({
organizationId: canonicalOrganizationId.data,
readOptions,
});
if (!readOptions || cached.status !== 'temporarily_unavailable') return cached;
}
signal?.throwIfAborted();
return refreshBitbucketWorkspaceAccessTokenRepositoriesForMember({
organizationId: canonicalOrganizationId.data,
kiloUserId,
readOptions,
});
}
if (integrationType === 'oauth') {
return listBitbucketRepositories({
owner: { type: 'org', id: canonicalOrganizationId.data },
kiloUserId,
forceRefresh,
readOptions,
});
}
if (integrationType || readOptions) return { status: 'reconnect_required' };
return { status: 'not_connected' };
}
);
return options?.bounded && integrationFound && result.status === 'not_connected'
? { status: 'temporarily_unavailable' }
: result;
} catch (error) {
if (!options?.bounded) throw error;
return { status: 'temporarily_unavailable' };
}
if (integrationType) return { status: 'reconnect_required' };
return { status: 'not_connected' };
}

export { BitbucketOrganizationRepositoryListResultSchema };
Expand Down
206 changes: 205 additions & 1 deletion apps/web/src/lib/cloud-agent/github-integration-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it, jest, beforeEach } from '@jest/globals';
import type { PlatformIntegration } from '@kilocode/db/schema';
import type { Owner } from '@/lib/integrations/core/types';
import type { RepositoryReadOptions } from '@/lib/integrations/core/repository-read-limits';

// Define mock functions at module level with proper typing
const mockGetIntegrationForOrganization =
Expand All @@ -14,7 +15,9 @@ const mockUpdateRepositoriesForIntegration =
const mockGetIntegrationsByOrganization =
jest.fn<(organizationId: string, platform: string) => Promise<PlatformIntegration[]>>();
const mockFetchGitHubRepositories =
jest.fn<(installationId: string, appType: string) => Promise<unknown[]>>();
jest.fn<
(installationId: string, appType: string, options?: RepositoryReadOptions) => Promise<unknown[]>
>();
const mockGenerateGitHubInstallationToken =
jest.fn<(installationId: string, appType: string) => Promise<{ token: string }>>();
const mockCheckExistingFork =
Expand Down Expand Up @@ -321,3 +324,204 @@ describe('github-integration-helpers', () => {
});
});
});

describe('bounded GitHub repository reads', () => {
beforeEach(() => {
jest.clearAllMocks();
mockFetchGitHubRepositories.mockReset();
mockUpdateRepositoriesForIntegration.mockReset();
});

describe.each(['personal', 'organization'] as const)('%s', scope => {
async function read(forceRefresh = false) {
const helpers = await import('./github-integration-helpers');
return scope === 'personal'
? helpers.fetchGitHubRepositoriesForUser('oauth/member', forceRefresh, { bounded: true })
: helpers.fetchAllGitHubRepositoriesForOrganization('org-123', forceRefresh, {
bounded: true,
});
}

function configure(integration: PlatformIntegration | null) {
mockGetIntegrationForOwner.mockResolvedValue(integration);
mockGetIntegrationsByOrganization.mockResolvedValue(integration ? [integration] : []);
}

it.each([
['absent', null, 'not_connected'],
['empty', buildIntegration({ repositories: [] }), 'available'],
['suspended', buildIntegration({ suspended_at: '2026-06-25 18:00:00+00' }), 'suspended'],
[
'auth-invalid',
buildIntegration({ auth_invalid_at: '2026-06-25 18:00:00+00' }),
'reconnect_required',
],
['misconfigured', buildIntegration({ platform_installation_id: null }), 'misconfigured'],
] as const)('keeps %s distinct without fetching', async (_label, integration, status) => {
configure(integration);
await expect(read()).resolves.toMatchObject({
status,
integrationInstalled: integration !== null,
repositories: [],
});
expect(mockFetchGitHubRepositories).not.toHaveBeenCalled();
});

it('bounds cached entries before validation and projection', async () => {
const repositories = Array.from({ length: 60 }, (_, id) => ({
id,
name: `repo-${id}`,
full_name: `org/repo-${id}`,
private: false,
}));
Object.defineProperty(repositories[50], 'id', {
get() {
throw new Error('Past the bound');
},
});
configure(buildIntegration({ repositories }));
const result = await read();
expect(result.status).toBe('available');
expect(result.repositories).toHaveLength(50);
expect(result.repositories.at(-1)?.fullName).toBe('org/repo-49');
});

it.each([false, true])(
'does not replace the complete cache on bounded refresh=%s',
async forceRefresh => {
const repositories = Array.from({ length: 60 }, (_, id) => ({
id,
name: `repo-${id}`,
full_name: `org/repo-${id}`,
private: false,
}));
const integration = buildIntegration({
repositories: forceRefresh ? repositories : null,
repositories_synced_at: forceRefresh ? '2024-01-01T00:00:00Z' : null,
});
configure(integration);
mockUpdateRepositoriesForIntegration.mockImplementation(async (_id, value) => {
integration.repositories = value as PlatformIntegration['repositories'];
});
mockFetchGitHubRepositories.mockImplementation(async (_id, _app, options) => {
if (!options?.bounded || !options.signal) throw new Error('Unbounded transport');
return repositories.slice(0, 50);
});
const result = await read(forceRefresh);
expect(result.status).toBe('available');
expect(result.repositories).toHaveLength(50);
mockFetchGitHubRepositories.mockResolvedValue(repositories);
const helpers = await import('./github-integration-helpers');
const legacy =
scope === 'personal'
? await helpers.fetchGitHubRepositoriesForUser('oauth/member')
: await helpers.fetchAllGitHubRepositoriesForOrganization('org-123');
expect(legacy.repositories).toHaveLength(60);
expect(legacy).not.toHaveProperty('status');
}
);

it('reports provider failure without exposing its raw error', async () => {
configure(buildIntegration({ repositories: null }));
mockFetchGitHubRepositories.mockRejectedValue(new Error('secret provider response'));
await expect(read()).resolves.toEqual({
status: 'temporarily_unavailable',
integrationInstalled: true,
repositories: [],
syncedAt: null,
});
});
});

it('rejects more than ten configured installations before network work', async () => {
mockGetIntegrationsByOrganization.mockResolvedValue(
Array.from({ length: 11 }, () => buildIntegration())
);
const { fetchAllGitHubRepositoriesForOrganization } =
await import('./github-integration-helpers');
await expect(
fetchAllGitHubRepositoriesForOrganization('org-123', true, { bounded: true })
).resolves.toMatchObject({ status: 'integration_limit_exceeded', repositories: [] });
expect(mockFetchGitHubRepositories).not.toHaveBeenCalled();
});

it('rejects an unavailable sibling instead of hiding it behind healthy repositories', async () => {
mockGetIntegrationsByOrganization.mockResolvedValue([
buildIntegration(),
buildIntegration({ auth_invalid_at: '2026-06-25 18:00:00+00' }),
]);
const { fetchAllGitHubRepositoriesForOrganization } =
await import('./github-integration-helpers');
await expect(
fetchAllGitHubRepositoriesForOrganization('org-123', false, { bounded: true })
).resolves.toMatchObject({ status: 'reconnect_required', repositories: [] });
});

it('rejects a later provider failure even after collecting fifty repositories', async () => {
mockGetIntegrationsByOrganization.mockResolvedValue([
buildIntegration({ repositories: Array.from({ length: 50 }, () => cachedRepositories[0]) }),
buildIntegration({ id: 'integration-2', repositories: null }),
]);
mockFetchGitHubRepositories.mockRejectedValue(new Error('unavailable'));
const { fetchAllGitHubRepositoriesForOrganization } =
await import('./github-integration-helpers');
await expect(
fetchAllGitHubRepositoriesForOrganization('org-123', false, { bounded: true })
).resolves.toMatchObject({ status: 'temporarily_unavailable', repositories: [] });
});

it('fetches sequentially and caps the aggregate while preserving installation provenance', async () => {
mockGetIntegrationsByOrganization.mockResolvedValue([
buildIntegration({ repositories: null }),
buildIntegration({
id: 'integration-2',
platform_installation_id: 'installation-2',
repositories: null,
}),
]);
let active = 0;
let peak = 0;
mockFetchGitHubRepositories.mockImplementation(async installationId => {
peak = Math.max(peak, ++active);
await Promise.resolve();
active--;
return Array.from({ length: 40 }, (_, id) => ({
id,
name: 'repo',
full_name: `${installationId}/repo-${id}`,
private: false,
}));
});
const { fetchAllGitHubRepositoriesForOrganization } =
await import('./github-integration-helpers');
const result = await fetchAllGitHubRepositoriesForOrganization('org-123', true, {
bounded: true,
});
expect(result.status).toBe('available');
expect(peak).toBe(1);
expect(result.repositories).toHaveLength(50);
expect(result.repositories.at(-1)).toMatchObject({
fullName: 'installation-2/repo-9',
platformIntegrationId: 'integration-2',
});
});

it('uses one deadline and starts no later installation after timeout', async () => {
jest.useFakeTimers();
try {
mockGetIntegrationsByOrganization.mockResolvedValue([buildIntegration(), buildIntegration()]);
mockFetchGitHubRepositories.mockImplementation(() => new Promise(() => {}));
const { fetchAllGitHubRepositoriesForOrganization } =
await import('./github-integration-helpers');
const result = fetchAllGitHubRepositoriesForOrganization('org-123', true, { bounded: true });
await jest.advanceTimersByTimeAsync(30_000);
await expect(result).resolves.toMatchObject({
status: 'temporarily_unavailable',
repositories: [],
});
expect(mockFetchGitHubRepositories).toHaveBeenCalledTimes(1);
} finally {
jest.useRealTimers();
}
});
});
Loading
Loading