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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ const mockFindKiloReviewComment = jest.fn();
const mockFetchPRInlineComments = jest.fn();
const mockGetPRHeadCommit = jest.fn();
const mockFetchGitHubRootTextFileAtRef = jest.fn();
const mockFetchGitHubRepositorySize = jest.fn();
const mockFindKiloReviewNote = jest.fn();
const mockFetchMRInlineComments = jest.fn();
const mockGetMRHeadCommit = jest.fn();
const mockGetMRDiffRefs = jest.fn();
const mockFetchGitLabRootTextFileAtRef = jest.fn();
const mockFetchGitLabRepositorySize = jest.fn();
const mockGetOrCreateProjectAccessToken = jest.fn();
const mockFindPreviousCompletedReview = jest.fn();
const mockUpdateRepositoryReviewInstructionsMetadata = jest.fn();
Expand All @@ -23,6 +25,7 @@ jest.mock('@/lib/integrations/platforms/github/adapter', () => ({
fetchPRInlineComments: (...args: unknown[]) => mockFetchPRInlineComments(...args),
getPRHeadCommit: (...args: unknown[]) => mockGetPRHeadCommit(...args),
fetchGitHubRootTextFileAtRef: (...args: unknown[]) => mockFetchGitHubRootTextFileAtRef(...args),
fetchGitHubRepositorySize: (...args: unknown[]) => mockFetchGitHubRepositorySize(...args),
}));

jest.mock('@/lib/integrations/platforms/gitlab/adapter', () => ({
Expand All @@ -31,6 +34,7 @@ jest.mock('@/lib/integrations/platforms/gitlab/adapter', () => ({
getMRHeadCommit: (...args: unknown[]) => mockGetMRHeadCommit(...args),
getMRDiffRefs: (...args: unknown[]) => mockGetMRDiffRefs(...args),
fetchGitLabRootTextFileAtRef: (...args: unknown[]) => mockFetchGitLabRootTextFileAtRef(...args),
fetchGitLabRepositorySize: (...args: unknown[]) => mockFetchGitLabRepositorySize(...args),
GitLabProjectAccessTokenPermissionError: class GitLabProjectAccessTokenPermissionError extends Error {},
}));

Expand Down Expand Up @@ -156,6 +160,7 @@ describe('prepareReviewPayload', () => {
mockFetchPRInlineComments.mockResolvedValue([]);
mockGetPRHeadCommit.mockResolvedValue('headsha123');
mockFetchGitHubRootTextFileAtRef.mockResolvedValue('# Review policy\n\nFlag only regressions.');
mockFetchGitHubRepositorySize.mockResolvedValue('100 MB');
mockFindKiloReviewNote.mockResolvedValue(null);
mockFetchMRInlineComments.mockResolvedValue([]);
mockGetMRHeadCommit.mockResolvedValue('headsha123');
Expand All @@ -165,6 +170,7 @@ describe('prepareReviewPayload', () => {
headSha: 'headsha123',
});
mockFetchGitLabRootTextFileAtRef.mockResolvedValue('# GitLab review policy');
mockFetchGitLabRepositorySize.mockResolvedValue('100 MB');
mockGetOrCreateProjectAccessToken.mockResolvedValue('gitlab-project-token');
mockFindPreviousCompletedReview.mockResolvedValue(null);
mockUpdateRepositoryReviewInstructionsMetadata.mockResolvedValue(undefined);
Expand All @@ -184,11 +190,13 @@ describe('prepareReviewPayload', () => {
mockFetchPRInlineComments.mockReset();
mockGetPRHeadCommit.mockReset();
mockFetchGitHubRootTextFileAtRef.mockReset();
mockFetchGitHubRepositorySize.mockReset();
mockFindKiloReviewNote.mockReset();
mockFetchMRInlineComments.mockReset();
mockGetMRHeadCommit.mockReset();
mockGetMRDiffRefs.mockReset();
mockFetchGitLabRootTextFileAtRef.mockReset();
mockFetchGitLabRepositorySize.mockReset();
mockGetOrCreateProjectAccessToken.mockReset();
mockFindPreviousCompletedReview.mockReset();
mockUpdateRepositoryReviewInstructionsMetadata.mockReset();
Expand All @@ -209,7 +217,7 @@ describe('prepareReviewPayload', () => {
.values(defineReview(testUser.id, integration.id))
.returning();

await prepareReviewPayload({
const payload = await prepareReviewPayload({
reviewId: review.id,
owner: { type: 'user', id: testUser.id, userId: testUser.id },
agentConfig: { config: baseAgentConfig },
Expand All @@ -223,6 +231,12 @@ describe('prepareReviewPayload', () => {
path: 'REVIEW.md',
ref: 'main',
});
expect(mockFetchGitHubRepositorySize).toHaveBeenCalledWith({
token: 'github-token',
owner: 'test-org',
repo: REPO.split('/')[1],
});
expect(payload.repositorySize).toBe('100 MB');
expect(mockGenerateReviewPrompt).toHaveBeenCalledWith(
expect.any(Object),
REPO,
Expand Down Expand Up @@ -268,7 +282,13 @@ describe('prepareReviewPayload', () => {
gitToken: 'gitlab-project-token',
platform: 'gitlab',
});
expect(payload.repositorySize).toBe('100 MB');
expect(payload.sessionInput).not.toHaveProperty('gitlabCodeReviewTokenRef');
expect(mockFetchGitLabRepositorySize).toHaveBeenCalledWith(
'gitlab-project-token',
REPO,
'https://gitlab.example.com'
);
expect(mockFindPreviousCompletedReview).toHaveBeenCalledWith(REPO, 123, 'headsha123', {
platform: 'gitlab',
integrationId: gitlabIntegration.id,
Expand Down Expand Up @@ -389,6 +409,29 @@ describe('prepareReviewPayload', () => {
});
});

it('continues payload preparation when repository size lookup fails', async () => {
const [review] = await db
.insert(cloud_agent_code_reviews)
.values(defineReview(testUser.id, integration.id))
.returning();
mockFetchGitHubRepositorySize.mockRejectedValueOnce(new Error('metadata unavailable'));

const payload = await prepareReviewPayload({
reviewId: review.id,
owner: { type: 'user', id: testUser.id, userId: testUser.id },
agentConfig: { config: baseAgentConfig },
platform: 'github',
});

expect(payload.repositorySize).toBeNull();
expect(mockGenerateReviewPrompt).toHaveBeenCalledWith(
expect.any(Object),
REPO,
123,
expect.any(Object)
);
});

it('falls back to built-in guidance when REVIEW.md is empty', async () => {
const [review] = await db
.insert(cloud_agent_code_reviews)
Expand Down
47 changes: 47 additions & 0 deletions apps/web/src/lib/code-reviews/triggers/prepare-review-payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
fetchPRInlineComments,
getPRHeadCommit,
fetchGitHubRootTextFileAtRef,
fetchGitHubRepositorySize,
} from '@/lib/integrations/platforms/github/adapter';
import type { GitHubAppType } from '@/lib/integrations/platforms/github/app-selector';
import {
Expand All @@ -27,6 +28,7 @@ import {
getMRDiffRefs,
GitLabProjectAccessTokenPermissionError,
fetchGitLabRootTextFileAtRef,
fetchGitLabRepositorySize,
} from '@/lib/integrations/platforms/gitlab/adapter';
import {
getOrCreateProjectAccessToken,
Expand Down Expand Up @@ -101,6 +103,8 @@ export type CodeReviewPayload = {
agentVersion?: string;
/** Cloud-agent session ID from a previous completed review, for session continuation */
previousCloudAgentSessionId?: string;
/** Provider-reported repository storage size, formatted for log correlation. */
repositorySize?: string | null;
};

/**
Expand Down Expand Up @@ -158,6 +162,7 @@ export async function prepareReviewPayload(
let existingReviewState: ExistingReviewState | null = null;
let gitlabContext: GitLabDiffContext | undefined;
let repositoryReviewInstructionsLookup = unusedRepositoryReviewInstructionsLookup();
let repositorySize: string | null = null;

if (review.platform_integration_id) {
const integration = await getIntegrationById(review.platform_integration_id);
Expand All @@ -175,6 +180,26 @@ export async function prepareReviewPayload(
githubToken = installationToken;
const [repoOwner, repoName] = review.repo_full_name.split('/');

try {
repositorySize = await fetchGitHubRepositorySize({
token: installationToken,
owner: repoOwner,
repo: repoName,
});
logExceptInTest('[prepareReviewPayload] Repository size lookup complete', {
platform,
repoFullName: review.repo_full_name,
repositorySize,
repositorySizeKnown: repositorySize !== null,
});
} catch (error) {
warnExceptInTest('[prepareReviewPayload] Repository size lookup failed; continuing', {
platform,
repoFullName: review.repo_full_name,
error: getReviewInstructionsFetchErrorMetadata(error),
});
}

const repositoryReviewInstructionsPromise =
shouldUseReviewMd && repoOwner && repoName
? fetchRepositoryReviewInstructions({
Expand Down Expand Up @@ -289,6 +314,26 @@ export async function prepareReviewPayload(
}
const projectAccessToken = gitlabToken;

try {
repositorySize = await fetchGitLabRepositorySize(
projectAccessToken,
review.repo_full_name,
instanceUrl
);
logExceptInTest('[prepareReviewPayload] Repository size lookup complete', {
platform,
repoFullName: review.repo_full_name,
repositorySize,
repositorySizeKnown: repositorySize !== null,
});
} catch (error) {
warnExceptInTest('[prepareReviewPayload] Repository size lookup failed; continuing', {
platform,
repoFullName: review.repo_full_name,
error: getReviewInstructionsFetchErrorMetadata(error),
});
}

const repositoryReviewInstructionsPromise = shouldUseReviewMd
? fetchRepositoryReviewInstructions({
platform,
Expand Down Expand Up @@ -523,12 +568,14 @@ export async function prepareReviewPayload(
sessionInput,
owner,
previousCloudAgentSessionId,
repositorySize,
};

logExceptInTest('[prepareReviewPayload] Prepared payload', {
reviewId,
platform,
owner,
repositorySize,
sessionInput: {
...sessionInput,
githubToken: sessionInput.githubToken ? '***' : undefined, // Redact token
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/lib/integrations/platforms/github/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,22 @@ export async function fetchGitHubRepositoryDefaultBranch(params: {
return data.default_branch;
}

export async function fetchGitHubRepositorySize(params: {
token: string;
owner: string;
repo: string;
}): Promise<string | null> {
const { token, owner, repo } = params;
const octokit = new Octokit({ auth: token });
const { data } = await octokit.repos.get({ owner, repo });

if (typeof data.size !== 'number') {
return null;
}

return `${Math.round(data.size / 1024)} MiB`;
}

export async function createGitHubBranch(params: {
token: string;
owner: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,27 @@ process.env.GITHUB_LITE_APP_ID = 'test-lite-app-id';
process.env.GITHUB_LITE_APP_PRIVATE_KEY = 'test-lite-private-key';

const mockGetContent = jest.fn();
const mockGet = jest.fn();

jest.mock('@octokit/rest', () => ({
Octokit: jest.fn().mockImplementation(() => ({
repos: { getContent: mockGetContent },
repos: { getContent: mockGetContent, get: mockGet },
})),
}));

import { decodeGitHubBase64Content, fetchGitHubRootTextFileAtRef } from './adapter';
import {
decodeGitHubBase64Content,
fetchGitHubRepositorySize,
fetchGitHubRootTextFileAtRef,
} from './adapter';

function httpError(status: number) {
return Object.assign(new Error(`HTTP ${status}`), { status });
}

beforeEach(() => {
mockGetContent.mockReset();
mockGet.mockReset();
});

describe('decodeGitHubBase64Content', () => {
Expand Down Expand Up @@ -98,3 +104,24 @@ describe('fetchGitHubRootTextFileAtRef', () => {
await expect(fetchGitHubRootTextFileAtRef(params)).rejects.toBe(error);
});
});

describe('fetchGitHubRepositorySize', () => {
const params = {
token: 'mock-token',
owner: 'acme',
repo: 'widgets',
};

it('fetches and formats the repository size reported in KiB', async () => {
mockGet.mockResolvedValueOnce({ data: { size: 102_400 } });

await expect(fetchGitHubRepositorySize(params)).resolves.toBe('100 MiB');
expect(mockGet).toHaveBeenCalledWith({ owner: 'acme', repo: 'widgets' });
});

it('formats zero-sized repositories explicitly', async () => {
mockGet.mockResolvedValueOnce({ data: { size: 0 } });

await expect(fetchGitHubRepositorySize(params)).resolves.toBe('0 MiB');
});
});
35 changes: 35 additions & 0 deletions apps/web/src/lib/integrations/platforms/gitlab/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
searchGitLabProjects,
normalizeGitLabSearchQuery,
fetchGitLabRootTextFileAtRef,
fetchGitLabRepositorySize,
} from './adapter';

// Mock fetch globally
Expand Down Expand Up @@ -770,6 +771,40 @@ describe('fetchGitLabRootTextFileAtRef', () => {
});
});

describe('fetchGitLabRepositorySize', () => {
beforeEach(() => {
mockFetch.mockReset();
});

it('fetches project statistics and formats repository_size bytes as MiB', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ statistics: { repository_size: 104_857_600 } }),
});

const result = await fetchGitLabRepositorySize('test-token', 'group/project');

expect(result).toBe('100 MiB');
expect(mockFetch).toHaveBeenCalledWith(
'https://gitlab.com/api/v4/projects/group%2Fproject?statistics=true',
expect.objectContaining({
headers: {
Authorization: 'Bearer test-token',
},
})
);
});

it('formats zero-sized repositories explicitly', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ statistics: { repository_size: 0 } }),
});

await expect(fetchGitLabRepositorySize('test-token', 'group/project')).resolves.toBe('0 MiB');
});
});

describe('deleteProjectWebhook', () => {
beforeEach(() => {
mockFetch.mockReset();
Expand Down
Loading