From 6f9b13849bc23239c0eb8b529c0156576fce4f0e Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 19 Jun 2026 21:31:19 +0300 Subject: [PATCH] chore(dev): add local review webhook harness --- .gitignore | 2 + apps/web/src/app/api/webhooks/github/route.ts | 2 +- apps/web/src/lib/code-reviews/local-dev.ts | 8 + .../triggers/prepare-review-payload.ts | 437 ++++++++++-------- .../webhook-handlers/installation-handler.ts | 2 +- .../webhook-handlers/pull-request-handler.ts | 36 +- .../webhook-handlers/merge-request-handler.ts | 10 +- dev/AGENTS.md | 124 +++-- dev/local/cli.ts | 12 +- dev/local/services.ts | 45 +- .../fixtures/github-pull-request-opened.json | 44 ++ .../fixtures/gitlab-merge-request-open.json | 38 ++ dev/review/test-review-webhook.sh | 275 +++++++---- dev/seed/review/webhook-fixtures.ts | 279 +++++++++++ services/cloud-agent-next/.dev.vars.example | 5 + .../cloudflare/cloudflare-agent-sandbox.ts | 68 +++ .../cloud-agent-next/src/persistence/types.ts | 2 + .../cloud-agent-next/src/session-service.ts | 101 +++- services/cloud-agent-next/src/types.ts | 2 + .../test/e2e/fake-llm-server.ts | 36 ++ 20 files changed, 1188 insertions(+), 340 deletions(-) create mode 100644 apps/web/src/lib/code-reviews/local-dev.ts create mode 100644 dev/review/fixtures/github-pull-request-opened.json create mode 100644 dev/review/fixtures/gitlab-merge-request-open.json create mode 100644 dev/seed/review/webhook-fixtures.ts diff --git a/.gitignore b/.gitignore index b54c998ca7..b34432e1c8 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,8 @@ next-env.d.ts /dev/*/.dev-logs/ /dev/fixtures/ /dev/*/fixtures/ +!/dev/review/fixtures/ +!/dev/review/fixtures/*.json dev-debug-request-logs/* **/dev-debug-request-logs/ /dev/logs/ diff --git a/apps/web/src/app/api/webhooks/github/route.ts b/apps/web/src/app/api/webhooks/github/route.ts index 3c32b653bd..af97a9fa40 100644 --- a/apps/web/src/app/api/webhooks/github/route.ts +++ b/apps/web/src/app/api/webhooks/github/route.ts @@ -1,6 +1,5 @@ import { NextRequest, after } from 'next/server'; import { captureException } from '@sentry/nextjs'; -import { bot } from '@/lib/bot'; import { handleGitHubWebhook } from '@/lib/integrations/platforms/github/webhook-handler'; function cloneGitHubRequest(request: NextRequest, rawBody: string) { @@ -24,6 +23,7 @@ export async function POST(request: NextRequest) { after(async () => { try { + const { bot } = await import('@/lib/bot'); const response = await bot.webhooks.github(botRequest, { waitUntil: task => after(() => task), }); diff --git a/apps/web/src/lib/code-reviews/local-dev.ts b/apps/web/src/lib/code-reviews/local-dev.ts new file mode 100644 index 0000000000..2e5456c094 --- /dev/null +++ b/apps/web/src/lib/code-reviews/local-dev.ts @@ -0,0 +1,8 @@ +const LOCAL_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']); + +export function isLocalCodeReviewFakeProviderEnabled(): boolean { + if (process.env.NODE_ENV === 'production') return false; + + const value = process.env.CODE_REVIEW_LOCAL_FAKE_PROVIDER?.trim().toLowerCase(); + return value !== undefined && LOCAL_TRUE_VALUES.has(value); +} diff --git a/apps/web/src/lib/code-reviews/triggers/prepare-review-payload.ts b/apps/web/src/lib/code-reviews/triggers/prepare-review-payload.ts index d260d0d69a..175d63f825 100644 --- a/apps/web/src/lib/code-reviews/triggers/prepare-review-payload.ts +++ b/apps/web/src/lib/code-reviews/triggers/prepare-review-payload.ts @@ -60,6 +60,7 @@ import { import { getCurrentReviewSummaryForContext } from '../summary/history'; import { PLATFORM } from '@/lib/integrations/core/constants'; import { getGitHubPullRequestCheckoutRef } from '@/lib/integrations/platforms/github/webhook-handlers/pull-request-checkout-ref'; +import { isLocalCodeReviewFakeProviderEnabled } from '../local-dev'; export type PreparePayloadParams = { reviewId: string; @@ -120,6 +121,7 @@ export async function prepareReviewPayload( const { reviewId, owner, agentConfig, platform = 'github' } = params; const config = agentConfig.config as CodeReviewAgentConfig; const shouldUseReviewMd = config.disable_review_md === false; + const useLocalFakeProvider = isLocalCodeReviewFakeProviderEnabled(); logExceptInTest('[prepareReviewPayload] Starting payload preparation', { reviewId, @@ -170,97 +172,117 @@ export async function prepareReviewPayload( const integration = await getIntegrationById(review.platform_integration_id); if (platform === 'github' && integration?.platform_installation_id) { - const installationId = integration.platform_installation_id; - // Use the stored app type (defaults to 'standard' for existing integrations) - const appType: GitHubAppType = integration.github_app_type || 'standard'; - // GitHub: Use installation token. Auth failures here (e.g. IP allow list - // blocking, suspended/uninstalled app) are hard failures: without a token - // we cannot clone private repos or post review comments. Let the error - // propagate so the user sees a meaningful failure on the review. - const tokenData = await generateGitHubInstallationToken(installationId, appType); - const installationToken = tokenData.token; - 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, + if (useLocalFakeProvider) { + githubToken = 'kilocode-local-fake-github-token'; + logExceptInTest('[prepareReviewPayload] Using local fake GitHub provider', { + reviewId, repoFullName: review.repo_full_name, - error: getReviewInstructionsFetchErrorMetadata(error), }); - } + } else { + const installationId = integration.platform_installation_id; + // Use the stored app type (defaults to 'standard' for existing integrations) + const appType: GitHubAppType = integration.github_app_type || 'standard'; + // GitHub: Use installation token. Auth failures here (e.g. IP allow list + // blocking, suspended/uninstalled app) are hard failures: without a token + // we cannot clone private repos or post review comments. Let the error + // propagate so the user sees a meaningful failure on the review. + const tokenData = await generateGitHubInstallationToken(installationId, appType); + const installationToken = tokenData.token; + 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({ + const repositoryReviewInstructionsPromise = + shouldUseReviewMd && repoOwner && repoName + ? fetchRepositoryReviewInstructions({ + platform, + repoFullName: review.repo_full_name, + baseRef: review.base_ref, + fetchInstructions: () => + fetchGitHubRootTextFileAtRef({ + token: installationToken, + owner: repoOwner, + repo: repoName, + path: REVIEW_INSTRUCTIONS_FILE, + ref: review.base_ref, + }), + }) + : undefined; + + if (shouldUseReviewMd && (!repoOwner || !repoName)) { + warnExceptInTest( + '[prepareReviewPayload] Cannot fetch REVIEW.md for invalid GitHub repo', + { platform, repoFullName: review.repo_full_name, baseRef: review.base_ref, - fetchInstructions: () => - fetchGitHubRootTextFileAtRef({ - token: installationToken, - owner: repoOwner, - repo: repoName, - path: REVIEW_INSTRUCTIONS_FILE, - ref: review.base_ref, - }), - }) - : undefined; + } + ); + } - if (shouldUseReviewMd && (!repoOwner || !repoName)) { - warnExceptInTest( - '[prepareReviewPayload] Cannot fetch REVIEW.md for invalid GitHub repo', - { - platform, - repoFullName: review.repo_full_name, - baseRef: review.base_ref, - } - ); - } + // Build complete review state for intelligent update/create decisions + try { + // Fetch all state in parallel for efficiency + const [summaryComment, inlineComments, headCommitSha, reviewInstructions] = + await Promise.all([ + findKiloReviewComment( + installationId, + repoOwner, + repoName, + review.pr_number, + appType + ), + fetchPRInlineComments( + installationId, + repoOwner, + repoName, + review.pr_number, + appType + ), + getPRHeadCommit(installationId, repoOwner, repoName, review.pr_number, appType), + repositoryReviewInstructionsPromise ?? + Promise.resolve(repositoryReviewInstructionsLookup), + ]); + repositoryReviewInstructionsLookup = reviewInstructions; - // Build complete review state for intelligent update/create decisions - try { - // Fetch all state in parallel for efficiency - const [summaryComment, inlineComments, headCommitSha, reviewInstructions] = - await Promise.all([ - findKiloReviewComment(installationId, repoOwner, repoName, review.pr_number, appType), - fetchPRInlineComments(installationId, repoOwner, repoName, review.pr_number, appType), - getPRHeadCommit(installationId, repoOwner, repoName, review.pr_number, appType), - repositoryReviewInstructionsPromise ?? - Promise.resolve(repositoryReviewInstructionsLookup), - ]); - repositoryReviewInstructionsLookup = reviewInstructions; - - existingReviewState = buildReviewState(summaryComment, inlineComments, headCommitSha); - - logExceptInTest('[prepareReviewPayload] Built GitHub review state', { - reviewId, - hasSummary: !!summaryComment, - inlineCount: inlineComments.length, - previousStatus: existingReviewState.previousStatus, - headCommitSha: headCommitSha.substring(0, 8), - }); - } catch (stateLookupError) { - if (repositoryReviewInstructionsPromise) { - repositoryReviewInstructionsLookup = await repositoryReviewInstructionsPromise; + existingReviewState = buildReviewState(summaryComment, inlineComments, headCommitSha); + + logExceptInTest('[prepareReviewPayload] Built GitHub review state', { + reviewId, + hasSummary: !!summaryComment, + inlineCount: inlineComments.length, + previousStatus: existingReviewState.previousStatus, + headCommitSha: headCommitSha.substring(0, 8), + }); + } catch (stateLookupError) { + if (repositoryReviewInstructionsPromise) { + repositoryReviewInstructionsLookup = await repositoryReviewInstructionsPromise; + } + // Non-critical - continue without state info + logExceptInTest('[prepareReviewPayload] Failed to build GitHub review state:', { + reviewId, + error: stateLookupError, + }); } - // Non-critical - continue without state info - logExceptInTest('[prepareReviewPayload] Failed to build GitHub review state:', { - reviewId, - error: stateLookupError, - }); } } else if (platform === 'gitlab' && integration) { // GitLab: Use Project Access Token (PrAT) for all operations @@ -279,140 +301,149 @@ export async function prepareReviewPayload( instanceUrl, }); - // Get or create Project Access Token (PrAT) for all GitLab operations - const projectId = review.platform_project_id; - - if (!projectId) { - throw new Error( - `GitLab code review requires platform_project_id. ` + - `Review ${reviewId} for ${review.repo_full_name} is missing this field.` - ); - } - - try { - gitlabToken = await getOrCreateProjectAccessToken(integration, projectId); - reviewContinuationScope = { - platform: 'gitlab', - integrationId: integration.id, - projectId, - }; - logExceptInTest('[prepareReviewPayload] Using PrAT for code review', { + if (useLocalFakeProvider) { + gitlabToken = 'kilocode-local-fake-gitlab-token'; + logExceptInTest('[prepareReviewPayload] Using local fake GitLab provider', { reviewId, repoFullName: review.repo_full_name, - projectId, + instanceUrl, }); - } catch (pratError) { - if (pratError instanceof GitLabProjectAccessTokenPermissionError) { + } else { + // Get or create Project Access Token (PrAT) for all GitLab operations + const projectId = review.platform_project_id; + + if (!projectId) { throw new Error( - `Cannot create Project Access Token for GitLab code review. ` + - `You need Maintainer role or higher on project ${review.repo_full_name}. ` + - `Error: ${pratError.message}` + `GitLab code review requires platform_project_id. ` + + `Review ${reviewId} for ${review.repo_full_name} is missing this field.` ); } - throw new Error( - `Failed to create Project Access Token for GitLab code review on ${review.repo_full_name}. ` + - `Error: ${pratError instanceof Error ? pratError.message : String(pratError)}` - ); - } - 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), - }); - } + try { + gitlabToken = await getOrCreateProjectAccessToken(integration, projectId); + reviewContinuationScope = { + platform: 'gitlab', + integrationId: integration.id, + projectId, + }; + logExceptInTest('[prepareReviewPayload] Using PrAT for code review', { + reviewId, + repoFullName: review.repo_full_name, + projectId, + }); + } catch (pratError) { + if (pratError instanceof GitLabProjectAccessTokenPermissionError) { + throw new Error( + `Cannot create Project Access Token for GitLab code review. ` + + `You need Maintainer role or higher on project ${review.repo_full_name}. ` + + `Error: ${pratError.message}` + ); + } + throw new Error( + `Failed to create Project Access Token for GitLab code review on ${review.repo_full_name}. ` + + `Error: ${pratError instanceof Error ? pratError.message : String(pratError)}` + ); + } + const projectAccessToken = gitlabToken; - const repositoryReviewInstructionsPromise = shouldUseReviewMd - ? fetchRepositoryReviewInstructions({ + try { + repositorySize = await fetchGitLabRepositorySize( + projectAccessToken, + review.repo_full_name, + instanceUrl + ); + logExceptInTest('[prepareReviewPayload] Repository size lookup complete', { platform, repoFullName: review.repo_full_name, - baseRef: review.base_ref, - fetchInstructions: () => - fetchGitLabRootTextFileAtRef( - projectAccessToken, - review.repo_full_name, - REVIEW_INSTRUCTIONS_FILE, - review.base_ref, - instanceUrl - ), - }) - : undefined; - - // Build complete review state for GitLab (using PrAT for reading) - try { - const mrIid = review.pr_number; - // Use repo_full_name as the project path for GitLab API calls - const repoPath = review.repo_full_name; - - // Fetch all state in parallel for efficiency (using PrAT) - const [summaryNote, inlineComments, headCommitSha, diffRefs, reviewInstructions] = - await Promise.all([ - findKiloReviewNote(gitlabToken, repoPath, mrIid, instanceUrl), - fetchMRInlineComments(gitlabToken, repoPath, mrIid, instanceUrl), - getMRHeadCommit(gitlabToken, repoPath, mrIid, instanceUrl), - getMRDiffRefs(gitlabToken, repoPath, mrIid, instanceUrl), - repositoryReviewInstructionsPromise ?? - Promise.resolve(repositoryReviewInstructionsLookup), - ]); - repositoryReviewInstructionsLookup = reviewInstructions; - - // Convert GitLab note format to common format - const summaryComment = summaryNote - ? { commentId: summaryNote.noteId, body: summaryNote.body } - : null; - - // Convert GitLab inline comments to common format - const convertedInlineComments = inlineComments.map(c => ({ - id: c.id, - path: c.path, - line: c.line, - body: c.body, - isOutdated: c.isOutdated, - })); - - existingReviewState = buildReviewState( - summaryComment, - convertedInlineComments, - headCommitSha - ); + repositorySize, + repositorySizeKnown: repositorySize !== null, + }); + } catch (error) { + warnExceptInTest('[prepareReviewPayload] Repository size lookup failed; continuing', { + platform, + repoFullName: review.repo_full_name, + error: getReviewInstructionsFetchErrorMetadata(error), + }); + } - // Store GitLab diff context for prompt generation - gitlabContext = { - baseSha: diffRefs.baseSha, - startSha: diffRefs.startSha, - headSha: diffRefs.headSha, - }; + const repositoryReviewInstructionsPromise = shouldUseReviewMd + ? fetchRepositoryReviewInstructions({ + platform, + repoFullName: review.repo_full_name, + baseRef: review.base_ref, + fetchInstructions: () => + fetchGitLabRootTextFileAtRef( + projectAccessToken, + review.repo_full_name, + REVIEW_INSTRUCTIONS_FILE, + review.base_ref, + instanceUrl + ), + }) + : undefined; - logExceptInTest('[prepareReviewPayload] Built GitLab review state', { - reviewId, - hasSummary: !!summaryNote, - inlineCount: inlineComments.length, - previousStatus: existingReviewState.previousStatus, - headCommitSha: headCommitSha.substring(0, 8), - }); - } catch (stateLookupError) { - if (repositoryReviewInstructionsPromise) { - repositoryReviewInstructionsLookup = await repositoryReviewInstructionsPromise; + // Build complete review state for GitLab (using PrAT for reading) + try { + const mrIid = review.pr_number; + // Use repo_full_name as the project path for GitLab API calls + const repoPath = review.repo_full_name; + + // Fetch all state in parallel for efficiency (using PrAT) + const [summaryNote, inlineComments, headCommitSha, diffRefs, reviewInstructions] = + await Promise.all([ + findKiloReviewNote(gitlabToken, repoPath, mrIid, instanceUrl), + fetchMRInlineComments(gitlabToken, repoPath, mrIid, instanceUrl), + getMRHeadCommit(gitlabToken, repoPath, mrIid, instanceUrl), + getMRDiffRefs(gitlabToken, repoPath, mrIid, instanceUrl), + repositoryReviewInstructionsPromise ?? + Promise.resolve(repositoryReviewInstructionsLookup), + ]); + repositoryReviewInstructionsLookup = reviewInstructions; + + // Convert GitLab note format to common format + const summaryComment = summaryNote + ? { commentId: summaryNote.noteId, body: summaryNote.body } + : null; + + // Convert GitLab inline comments to common format + const convertedInlineComments = inlineComments.map(c => ({ + id: c.id, + path: c.path, + line: c.line, + body: c.body, + isOutdated: c.isOutdated, + })); + + existingReviewState = buildReviewState( + summaryComment, + convertedInlineComments, + headCommitSha + ); + + // Store GitLab diff context for prompt generation + gitlabContext = { + baseSha: diffRefs.baseSha, + startSha: diffRefs.startSha, + headSha: diffRefs.headSha, + }; + + logExceptInTest('[prepareReviewPayload] Built GitLab review state', { + reviewId, + hasSummary: !!summaryNote, + inlineCount: inlineComments.length, + previousStatus: existingReviewState.previousStatus, + headCommitSha: headCommitSha.substring(0, 8), + }); + } catch (stateLookupError) { + if (repositoryReviewInstructionsPromise) { + repositoryReviewInstructionsLookup = await repositoryReviewInstructionsPromise; + } + // Non-critical - continue without state info + logExceptInTest('[prepareReviewPayload] Failed to build GitLab review state:', { + reviewId, + error: stateLookupError, + }); } - // Non-critical - continue without state info - logExceptInTest('[prepareReviewPayload] Failed to build GitLab review state:', { - reviewId, - error: stateLookupError, - }); } } } diff --git a/apps/web/src/lib/integrations/platforms/github/webhook-handlers/installation-handler.ts b/apps/web/src/lib/integrations/platforms/github/webhook-handlers/installation-handler.ts index b4e9f7792c..366d9cf78f 100644 --- a/apps/web/src/lib/integrations/platforms/github/webhook-handlers/installation-handler.ts +++ b/apps/web/src/lib/integrations/platforms/github/webhook-handlers/installation-handler.ts @@ -24,7 +24,6 @@ import { buildInstallationData } from '../webhook-helpers'; import { INTEGRATION_STATUS, PLATFORM } from '@/lib/integrations/core/constants'; import { logExceptInTest } from '@/lib/utils.server'; import { captureException } from '@sentry/nextjs'; -import { bot } from '@/lib/bot'; import { unlinkTeamKiloUsers } from '@/lib/bot-identity'; /** @@ -124,6 +123,7 @@ export async function handleInstallationDeleted(payload: InstallationDeletedPayl ); try { + const { bot } = await import('@/lib/bot'); await bot.initialize(); await unlinkTeamKiloUsers(bot.getState(), PLATFORM.GITHUB, installationIdStr); } catch (error) { diff --git a/apps/web/src/lib/integrations/platforms/github/webhook-handlers/pull-request-handler.ts b/apps/web/src/lib/integrations/platforms/github/webhook-handlers/pull-request-handler.ts index e011306348..31bd409f4a 100644 --- a/apps/web/src/lib/integrations/platforms/github/webhook-handlers/pull-request-handler.ts +++ b/apps/web/src/lib/integrations/platforms/github/webhook-handlers/pull-request-handler.ts @@ -28,6 +28,7 @@ import { updateCheckRunId } from '@/lib/code-reviews/db/code-reviews'; import { resolvePullRequestCheckoutRef } from './pull-request-checkout-ref'; import { APP_URL } from '@/lib/constants'; import { getCodeReviewActionRequiredState } from '@/lib/code-reviews/action-required'; +import { isLocalCodeReviewFakeProviderEnabled } from '@/lib/code-reviews/local-dev'; /** * GitHub Pull Request Event Handler @@ -155,6 +156,7 @@ export async function handlePullRequestCodeReview( const appType = integration.github_app_type ?? 'standard'; const headFullName = checkoutRef.headRepoFullName ?? repository.full_name; const [headOwner, headRepoName] = headFullName.split('/'); + const useLocalFakeProvider = isLocalCodeReviewFakeProviderEnabled(); // 4. Skip merge commits on synchronize (e.g. merging base branch into feature branch). // Runs before cancellation so that an in-flight review at an earlier SHA is preserved: @@ -320,7 +322,7 @@ export async function handlePullRequestCodeReview( const [repoOwner, repoName] = repository.full_name.split('/'); // 8. Create GitHub Check Run (PR gate) — skip for lite (read-only) app - if (appType !== 'lite') { + if (appType !== 'lite' && !useLocalFakeProvider) { let checkRunId: number | undefined; try { const detailsUrl = `${APP_URL}/code-reviews/${reviewId}`; @@ -369,18 +371,26 @@ export async function handlePullRequestCodeReview( } // 9. Post 👀 reaction to show Kilo is reviewing - try { - await addReactionToPR( - integration.platform_installation_id as string, - repoOwner, - repoName, - pull_request.number, - 'eyes' - ); - logExceptInTest(`Added eyes reaction to ${repository.full_name}#${pull_request.number}`); - } catch (reactionError) { - // Non-blocking - log but don't fail the review - logExceptInTest('Failed to add eyes reaction:', reactionError); + if (!useLocalFakeProvider) { + try { + await addReactionToPR( + integration.platform_installation_id as string, + repoOwner, + repoName, + pull_request.number, + 'eyes' + ); + logExceptInTest(`Added eyes reaction to ${repository.full_name}#${pull_request.number}`); + } catch (reactionError) { + // Non-blocking - log but don't fail the review + logExceptInTest('Failed to add eyes reaction:', reactionError); + } + } else { + logExceptInTest('Skipping GitHub provider side effects for local fake review', { + reviewId, + repo: repository.full_name, + prNumber: pull_request.number, + }); } // 10. Try to dispatch pending reviews (including this new one) diff --git a/apps/web/src/lib/integrations/platforms/gitlab/webhook-handlers/merge-request-handler.ts b/apps/web/src/lib/integrations/platforms/gitlab/webhook-handlers/merge-request-handler.ts index 21e190e550..00b10cdb7e 100644 --- a/apps/web/src/lib/integrations/platforms/gitlab/webhook-handlers/merge-request-handler.ts +++ b/apps/web/src/lib/integrations/platforms/gitlab/webhook-handlers/merge-request-handler.ts @@ -35,6 +35,7 @@ import { getValidGitLabToken, } from '@/lib/integrations/gitlab-service'; import { APP_URL } from '@/lib/constants'; +import { isLocalCodeReviewFakeProviderEnabled } from '@/lib/code-reviews/local-dev'; /** * Handles merge request events that trigger code review @@ -321,6 +322,7 @@ export async function handleMergeRequestCodeReview( // 8. Resolve checkout ref (fork MRs use refs/merge-requests//head) const { checkoutRef } = resolveMergeRequestCheckoutRef(payload); + const useLocalFakeProvider = isLocalCodeReviewFakeProviderEnabled(); // 9. Create review record (session_id will be updated async) const reviewId = await createCodeReview({ @@ -341,7 +343,7 @@ export async function handleMergeRequestCodeReview( logExceptInTest(`Created code review ${reviewId} for ${project.path_with_namespace}!${mr.iid}`); // 10. Post 👀 reaction and set commit status (using PrAT for bot identity) - if (fullIntegration) { + if (fullIntegration && !useLocalFakeProvider) { try { const pratToken = await getOrCreateProjectAccessToken(fullIntegration, project.id); logExceptInTest(`Got PrAT for project ${project.path_with_namespace}`, { @@ -379,6 +381,12 @@ export async function handleMergeRequestCodeReview( error: reactionError instanceof Error ? reactionError.message : String(reactionError), }); } + } else if (useLocalFakeProvider) { + logExceptInTest('Skipping GitLab provider side effects for local fake review', { + reviewId, + project: project.path_with_namespace, + mrIid: mr.iid, + }); } // 11. Try to dispatch pending reviews (including this new one) diff --git a/dev/AGENTS.md b/dev/AGENTS.md index f01aed32a2..cbb73a4acf 100644 --- a/dev/AGENTS.md +++ b/dev/AGENTS.md @@ -1,57 +1,105 @@ # Dev Script Guide For AI Agents -These webhook test scripts intentionally use generic placeholder JSON. -They are not expected to represent valid production payloads. -Preferred workflow: capture a real webhook from smee.io, then ask an AI to replace or provide that payload to the script. +Use the local fake-provider review flow first when debugging webhook-to-review behavior without a real repository. It exercises the Next.js webhook route, database routing, code-review Worker, reviewer Durable Object, cloud-agent-next Durable Object, and Docker sandbox. It avoids real GitHub/GitLab API and clone dependencies by using explicit local-only flags. -## Script Map +## Review Webhook Flow -- Review flow: - - `./dev/review/dev-review.sh` - - `./dev/review/test-review-webhook.sh [payload.json]` -- Auto-fix flow (`@kilo fix it`): - - `./dev/auto-fix/dev-auto-fix.sh` - - `./dev/auto-fix/test-auto-fix-webhook.sh [payload.json]` +1. Sync local dev env files after pulling changes: -## GitHub App Install Prerequisites +```bash +pnpm dev:env code-review +``` -1. Ensure local app secrets are configured, especially `GITHUB_APP_WEBHOOK_SECRET` in `.env.local`. -2. Install the GitHub App on the repo/org you want to test. -3. In the GitHub App webhook settings: +2. Start the code-review stack with the local fake provider enabled: + +```bash +CODE_REVIEW_LOCAL_FAKE_PROVIDER=1 KILO_PORT_OFFSET=auto pnpm dev:start --no-attach code-review +``` + +3. Seed fake integrations and refresh the tracked webhook fixtures: + +```bash +pnpm dev:seed review:webhook-fixtures +``` -- Set webhook URL to your smee channel URL: `https://smee.io/`. -- Set webhook secret to match your local webhook secret. -- Subscribe to required events: -- Review flow: `pull_request`. -- Auto-fix flow: `pull_request_review_comment`. +4. Send a signed GitHub pull request webhook and verify the generated review prompt reached fake-LLM: -## Forward GitHub Events Locally With smee.io +```bash +VERIFY_FAKE_LLM=1 ./dev/review/test-review-webhook.sh --github +``` -1. Create a channel at [smee.io](https://smee.io). -2. Run the relay locally: +5. Send a GitLab merge request webhook and verify the generated review prompt reached fake-LLM: + +```bash +VERIFY_FAKE_LLM=1 ./dev/review/test-review-webhook.sh --gitlab +``` + +Re-run `pnpm dev:seed review:webhook-fixtures` before repeating a fixture webhook. Review rows are unique by repo, PR/MR number, and head SHA, so reseeding clears the previous fixture review and webhook dedupe rows. + +## What The Fake Flag Does + +`CODE_REVIEW_LOCAL_FAKE_PROVIDER=1` is local-only and should not be used for production-like provider API testing. + +| Component | Behavior | +|---|---| +| Next.js webhook/review code | Skips GitHub/GitLab provider token reads, check/status writes, reactions, comments, repository size reads, and `REVIEW.md` reads. | +| Next.js and code-review Worker | The dev runner injects matching local `CALLBACK_TOKEN_SECRET` values so callback status updates work without interactive `dev:env`. | +| cloud-agent-next | Wrangler receives `KILOCODE_DEV_FAKE_REPOSITORY=1` and creates a synthetic git origin/ref inside the sandbox instead of cloning GitHub/GitLab. | +| fake-LLM | Wrangler receives `KILO_OPENROUTER_BASE=http://localhost:/api`; seeded prompts include `__fake__:idle` so no `gh`/`glab` CLI call is attempted. | +| test script | Discovers the active Next.js and fake-LLM ports with `pnpm dev:status --json`; never assumes port `3000`. | + +## Fixture Files + +The review webhook fixtures are tracked canonical examples: + +- `dev/review/fixtures/github-pull-request-opened.json` +- `dev/review/fixtures/gitlab-merge-request-open.json` + +`pnpm dev:seed review:webhook-fixtures` refreshes these files and resets the local fixture rows. The fixtures are based on GitHub pull request and GitLab merge request webhook shapes and include the fields the local handlers require. + +## Sending Custom Payloads + +Use captured payloads with the same sender script: + +```bash +./dev/review/test-review-webhook.sh --github /path/to/github-payload.json +./dev/review/test-review-webhook.sh --gitlab /path/to/gitlab-payload.json +``` + +Payloads wrapped as `{"event":"...","payload":{...}}` are unwrapped automatically. Override `EVENT_TYPE`, `WEBHOOK_URL`, `WEBHOOK_SECRET`, or `GITLAB_WEBHOOK_TOKEN` only when testing non-default routes or captured deliveries. + +For GitHub, the script signs the body with `WEBHOOK_SECRET`, `GITHUB_APP_WEBHOOK_SECRET`, or `GITHUB_APP_WEBHOOK_SECRET` parsed from `.env.local`. For GitLab, the seeded token is `dev-review-gitlab-webhook-secret`. + +## Real Provider Flow + +Use smee.io when you intentionally need real GitHub or GitLab provider behavior, including installation token generation, check/status updates, reactions, comments, `REVIEW.md`, and real repository cloning. + +1. Start the relevant local stack without `CODE_REVIEW_LOCAL_FAKE_PROVIDER=1`. +2. Create a channel at [smee.io](https://smee.io). +3. Forward GitHub webhooks locally: ```bash npx smee-client \ --url https://smee.io/ \ - --target http://127.0.0.1:3000/api/webhooks/github + --target http://127.0.0.1:/api/webhooks/github ``` -3. Keep this process running while testing. -4. Trigger a real GitHub event and capture the delivered JSON payload from smee. +4. Trigger a real provider event and save the delivered JSON payload. +5. Replay it with `./dev/review/test-review-webhook.sh --github payload.json` or the GitLab equivalent. -## How AI Agents Should Use The Test Scripts +Get `` from `pnpm dev:status --json` or `dev/logs/manifest.json`. + +## Script Map -1. Start the required local services using the matching `dev` script. -2. Save the real captured webhook payload to a JSON file. -3. Run the matching test script with that file path. -4. If payload is wrapped as `{"event":"...","payload":{...}}`, scripts auto-unwrap `.payload`. -5. If no file is provided, scripts send embedded generic placeholder JSON. +- Review flow: `./dev/review/test-review-webhook.sh [--github|--gitlab] [payload.json|-]` +- Legacy fixed-port review script: `./dev/review/dev-review.sh` +- Auto-fix flow: `./dev/auto-fix/dev-auto-fix.sh` +- Auto-fix webhook sender: `./dev/auto-fix/test-auto-fix-webhook.sh [payload.json]` -## Notes +## Logs -- Generic payload mode is for scaffolding only and may fail validation or integration checks. -- Real payloads from smee are the source of truth for local webhook debugging. -- Scripts sign payloads using `WEBHOOK_SECRET` (env override supported). -- Log files are written under: -- `dev/.dev-logs/review/` for review flow -- `dev/.dev-logs/auto-fix/` for auto-fix flow +- Dev manifest: `dev/logs/manifest.json` +- Next.js logs: `dev/logs/nextjs.log` +- Code-review Worker logs: `dev/logs/cloudflare-code-review-infra.log` +- cloud-agent-next logs: `dev/logs/cloud-agent-next.log` +- fake-LLM logs: `dev/logs/fake-llm.log` diff --git a/dev/local/cli.ts b/dev/local/cli.ts index a98dfd9059..38a29186d6 100644 --- a/dev/local/cli.ts +++ b/dev/local/cli.ts @@ -212,7 +212,13 @@ async function cmdUp(args: string[], repoRoot: string): Promise { PATH: sessionPath, WRANGLER_REGISTRY_PATH: wranglerRegistryPath, }; - for (const key of ['PNPM_HOME', 'COREPACK_HOME', 'npm_execpath']) { + for (const key of [ + 'PNPM_HOME', + 'COREPACK_HOME', + 'npm_execpath', + 'CODE_REVIEW_LOCAL_FAKE_PROVIDER', + 'KILOCODE_DEV_FAKE_REPOSITORY', + ]) { const value = process.env[key]; if (value !== undefined && value !== '') { sessionEnv[key] = value; @@ -534,7 +540,7 @@ async function cmdStatus(repoRoot: string, isJson = false): Promise { } } -async function cmdRestart(serviceName: string, repoRoot: string): Promise { +async function cmdRestart(serviceName: string): Promise { if (!services.has(serviceName)) { console.error(`Unknown service: ${serviceName}`); process.exit(1); @@ -656,7 +662,7 @@ async function main() { console.error('Usage: dev:restart '); process.exit(1); } - await cmdRestart(serviceName, repoRoot); + await cmdRestart(serviceName); break; } case 'env': diff --git a/dev/local/services.ts b/dev/local/services.ts index 5e377f8309..ba4d525e3a 100644 --- a/dev/local/services.ts +++ b/dev/local/services.ts @@ -386,11 +386,53 @@ export function getAllInfraProfiles(): string[] { const CONTAINER_EGRESS_IMAGE_ARM64 = 'cloudflare/proxy-everything:3cb1195@sha256:78c7910f4575a511d928d7824b1cbcaec6b7c4bf4dbb3fafaeeae3104030e73c'; +const LOCAL_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']); +const LOCAL_REVIEW_CALLBACK_TOKEN_SECRET = 'kilocode-local-review-callback-secret'; + function containerEgressImageEnvPrefix(): string[] { if (process.arch !== 'arm64') return []; return ['env', `MINIFLARE_CONTAINER_EGRESS_IMAGE=${CONTAINER_EGRESS_IMAGE_ARM64}`]; } +function envFlagEnabled(name: string): boolean { + const value = process.env[name]?.trim().toLowerCase(); + return value !== undefined && LOCAL_TRUE_VALUES.has(value); +} + +function workerDevVarArgs(serviceName: string): string[] { + if ( + !envFlagEnabled('CODE_REVIEW_LOCAL_FAKE_PROVIDER') && + !envFlagEnabled('KILOCODE_DEV_FAKE_REPOSITORY') + ) { + return []; + } + + if (serviceName === 'cloudflare-code-review-infra') { + return ['--var', `CALLBACK_TOKEN_SECRET:${LOCAL_REVIEW_CALLBACK_TOKEN_SECRET}`]; + } + + if (serviceName !== 'cloud-agent-next') return []; + + const fakeLlmPort = 8811 + portOffset; + return [ + '--var', + 'KILOCODE_DEV_FAKE_REPOSITORY:1', + '--var', + `KILO_OPENROUTER_BASE:http://localhost:${fakeLlmPort}/api`, + ]; +} + +function nextjsCommand(): string[] { + if (!envFlagEnabled('CODE_REVIEW_LOCAL_FAKE_PROVIDER')) return ['pnpm', 'run', 'dev']; + return [ + 'env', + `CALLBACK_TOKEN_SECRET=${LOCAL_REVIEW_CALLBACK_TOKEN_SECRET}`, + 'pnpm', + 'run', + 'dev', + ]; +} + function buildServiceDefs(): ServiceDef[] { const repoRoot = path.resolve(import.meta.dirname, '../..'); const defs: ServiceDef[] = []; @@ -405,7 +447,7 @@ function buildServiceDefs(): ServiceDef[] { dir: 'apps/web', port: nextjsTargetPort, dependsOn: meta.dependsOn, - command: ['pnpm', 'run', 'dev'], + command: nextjsCommand(), group: meta.group, }); continue; @@ -547,6 +589,7 @@ function buildServiceDefs(): ServiceDef[] { String(inspectorPort), '--ip', '0.0.0.0', + ...workerDevVarArgs(name), ]; defs.push({ diff --git a/dev/review/fixtures/github-pull-request-opened.json b/dev/review/fixtures/github-pull-request-opened.json new file mode 100644 index 0000000000..57abdb7b01 --- /dev/null +++ b/dev/review/fixtures/github-pull-request-opened.json @@ -0,0 +1,44 @@ +{ + "action": "opened", + "number": 123, + "pull_request": { + "number": 123, + "title": "Exercise local code review webhook flow", + "body": "Fixture PR used to test the local Kilo Code review webhook path.", + "state": "open", + "draft": false, + "html_url": "https://github.com/kilo-dev/review-fixture/pull/123", + "user": { + "id": 583231, + "login": "octocat", + "avatar_url": "https://github.com/images/error/octocat_happy.gif" + }, + "head": { + "sha": "1111111111111111111111111111111111111111", + "ref": "feature/local-review-fixture", + "repo": { + "full_name": "kilo-dev/review-fixture", + "clone_url": "https://github.com/kilo-dev/review-fixture.git" + } + }, + "base": { + "sha": "2222222222222222222222222222222222222222", + "ref": "main" + } + }, + "repository": { + "id": 987654, + "name": "review-fixture", + "full_name": "kilo-dev/review-fixture", + "private": false, + "owner": { + "login": "kilo-dev" + } + }, + "installation": { + "id": 987654321 + }, + "sender": { + "login": "octocat" + } +} diff --git a/dev/review/fixtures/gitlab-merge-request-open.json b/dev/review/fixtures/gitlab-merge-request-open.json new file mode 100644 index 0000000000..68b9a560e9 --- /dev/null +++ b/dev/review/fixtures/gitlab-merge-request-open.json @@ -0,0 +1,38 @@ +{ + "object_kind": "merge_request", + "event_type": "merge_request", + "user": { + "id": 583231, + "name": "Mona Octocat", + "username": "octocat" + }, + "project": { + "id": 987654, + "name": "gitlab-review-fixture", + "web_url": "https://gitlab.example.com/kilo-dev/gitlab-review-fixture", + "namespace": "kilo-dev", + "path_with_namespace": "kilo-dev/gitlab-review-fixture", + "default_branch": "main" + }, + "object_attributes": { + "id": 456789, + "iid": 123, + "title": "Exercise local GitLab review webhook flow", + "state": "opened", + "action": "open", + "source_branch": "feature/local-review-fixture", + "target_branch": "main", + "source_project_id": 987654, + "target_project_id": 987654, + "author_id": 583231, + "created_at": "2026-06-19T00:00:00Z", + "updated_at": "2026-06-19T00:00:00Z", + "url": "https://gitlab.example.com/kilo-dev/gitlab-review-fixture/-/merge_requests/123", + "draft": false, + "work_in_progress": false, + "last_commit": { + "id": "3333333333333333333333333333333333333333", + "message": "Add local review fixture change" + } + } +} diff --git a/dev/review/test-review-webhook.sh b/dev/review/test-review-webhook.sh index 99b0c31587..b9f78b744d 100755 --- a/dev/review/test-review-webhook.sh +++ b/dev/review/test-review-webhook.sh @@ -1,103 +1,222 @@ #!/usr/bin/env bash set -euo pipefail -# Intentionally generic test payload. -# Ask an AI to replace with a real webhook payload captured from smee.io. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -WEBHOOK_URL="${WEBHOOK_URL:-http://127.0.0.1:3000/api/webhooks/github}" -WEBHOOK_SECRET="${WEBHOOK_SECRET:-dausigdb781g287d9asgd9721dsa}" -EVENT_TYPE="${EVENT_TYPE:-}" -DEFAULT_EVENT_TYPE="pull_request" -DELIVERY_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')" +usage() { + cat <<'EOF' +Usage: ./dev/review/test-review-webhook.sh [--github|--gitlab] [payload.json|-] -# Optional first arg: path to JSON file containing a real GitHub webhook body. -PAYLOAD_FILE="${1:-}" +Environment: + PLATFORM=github|gitlab Platform when no flag is provided. + WEBHOOK_URL=... Override target URL. + WEBHOOK_SECRET=... GitHub HMAC secret. Defaults to GITHUB_APP_WEBHOOK_SECRET. + GITLAB_WEBHOOK_TOKEN=... GitLab token. Defaults to the local seed token. + VERIFY_FAKE_LLM=1 Poll fake-llm /test/prompts for the generated prompt. + VERIFY_TIMEOUT_SECONDS=180 Fake-LLM verification timeout. -GENERIC_BODY='{ - "action": "opened", - "number": 123, - "pull_request": { - "number": 123, - "title": "PLACEHOLDER: Replace with real PR title", - "body": "PLACEHOLDER: Replace with real PR body", - "state": "open", - "draft": false, - "html_url": "https://github.com/OWNER/REPO/pull/123", - "user": { - "id": 1, - "login": "octocat", - "avatar_url": "https://github.com/images/error/octocat_happy.gif" - }, - "head": { - "sha": "1111111111111111111111111111111111111111", - "ref": "feature/placeholder", - "repo": { - "full_name": "OWNER/REPO" - } - }, - "base": { - "sha": "2222222222222222222222222222222222222222", - "ref": "main" - } - }, - "repository": { - "id": 1, - "name": "REPO", - "full_name": "OWNER/REPO", - "private": false, - "owner": { - "login": "OWNER" +Default payloads are written by: + pnpm dev:seed review:webhook-fixtures +EOF +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2 + exit 1 + fi +} + +service_port() { + local service_name="$1" + printf '%s' "$DEV_STATUS" | jq -r --arg name "$service_name" ' + [.services[] | select(.name == $name and .status == "up") | .port][0] // empty + ' +} + +load_dotenv_value() { + local key="$1" + node -e ' +const fs = require("node:fs"); +const key = process.argv[1]; +for (const file of [".env.local", "apps/web/.env.local"]) { + if (!fs.existsSync(file)) continue; + for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) { + const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (!match || match[1] !== key) continue; + let value = match[2].trim(); + if (value.length >= 2 && value.charCodeAt(0) === value.charCodeAt(value.length - 1) && [34, 39].includes(value.charCodeAt(0))) { + value = value.slice(1, -1); } - }, - "installation": { - "id": 12345678 - }, - "sender": { - "login": "octocat" + process.stdout.write(value); + process.exit(0); } -}' +} +process.exit(1); +' "$key" +} + +PLATFORM="${PLATFORM:-github}" +case "${1:-}" in + --github) + PLATFORM="github" + shift + ;; + --gitlab) + PLATFORM="gitlab" + shift + ;; + --help|-h) + usage + exit 0 + ;; +esac + +if [ "$PLATFORM" != "github" ] && [ "$PLATFORM" != "gitlab" ]; then + echo "PLATFORM must be github or gitlab" >&2 + exit 1 +fi + +require_command curl +require_command jq +require_command node +require_command openssl +require_command uuidgen + +DEV_STATUS="$(pnpm -s dev:status --json)" +NEXTJS_PORT="$(service_port nextjs)" +if [ -z "$NEXTJS_PORT" ] && [ -z "${WEBHOOK_URL:-}" ]; then + echo "nextjs is not running. Start the review stack first:" >&2 + echo " CODE_REVIEW_LOCAL_FAKE_PROVIDER=1 KILO_PORT_OFFSET=auto pnpm dev:start --no-attach code-review" >&2 + echo "Or set WEBHOOK_URL to a running local Next.js webhook endpoint." >&2 + exit 1 +fi + +FAKE_LLM_PORT="" +FAKE_LLM_BASELINE_REQ_ID="0" +if [ "${VERIFY_FAKE_LLM:-0}" = "1" ]; then + FAKE_LLM_PORT="$(service_port fake-llm)" + if [ -z "$FAKE_LLM_PORT" ]; then + echo "fake-llm is not running; cannot verify generated prompt." >&2 + exit 1 + fi + BASELINE_PROMPTS="$(curl -fsS "http://127.0.0.1:$FAKE_LLM_PORT/test/prompts" 2>/dev/null || true)" + if [ -n "$BASELINE_PROMPTS" ]; then + FAKE_LLM_BASELINE_REQ_ID="$(printf '%s' "$BASELINE_PROMPTS" | jq -r '[.prompts[]?.reqId // 0] | max // 0')" + fi +fi + +DEFAULT_GITHUB_FIXTURE="$REPO_ROOT/dev/review/fixtures/github-pull-request-opened.json" +DEFAULT_GITLAB_FIXTURE="$REPO_ROOT/dev/review/fixtures/gitlab-merge-request-open.json" +PAYLOAD_FILE="${1:-}" + +if [ -z "$PAYLOAD_FILE" ]; then + if [ "$PLATFORM" = "github" ]; then + PAYLOAD_FILE="$DEFAULT_GITHUB_FIXTURE" + else + PAYLOAD_FILE="$DEFAULT_GITLAB_FIXTURE" + fi +fi if [ "$PAYLOAD_FILE" = "-" ]; then RAW_BODY="$(cat)" PAYLOAD_SOURCE="stdin" -elif [ -n "$PAYLOAD_FILE" ]; then +elif [ -f "$PAYLOAD_FILE" ]; then RAW_BODY="$(cat "$PAYLOAD_FILE")" PAYLOAD_SOURCE="$PAYLOAD_FILE" else - RAW_BODY="$GENERIC_BODY" - PAYLOAD_SOURCE="embedded generic payload" + echo "Payload file not found: $PAYLOAD_FILE" >&2 + echo "Create local fixtures with: pnpm dev:seed review:webhook-fixtures" >&2 + exit 1 fi -# Prefer explicit EVENT_TYPE env var, otherwise infer from wrapped payload .event. -DETECTED_EVENT="$(printf '%s' "$RAW_BODY" | jq -r 'if (type == "object" and has("event") and (.event | type == "string")) then .event else empty end')" -if [ -n "$EVENT_TYPE" ]; then - FINAL_EVENT_TYPE="$EVENT_TYPE" -elif [ -n "$DETECTED_EVENT" ]; then - FINAL_EVENT_TYPE="$DETECTED_EVENT" +BODY="$(printf '%s' "$RAW_BODY" | jq -c 'if (type == "object" and has("payload")) then .payload else . end')" +DELIVERY_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')" +RESPONSE_FILE="$(mktemp)" +trap 'rm -f "$RESPONSE_FILE"' EXIT + +if [ "$PLATFORM" = "github" ]; then + WEBHOOK_URL="${WEBHOOK_URL:-http://127.0.0.1:$NEXTJS_PORT/api/webhooks/github}" + DEFAULT_EVENT_TYPE="pull_request" + DETECTED_EVENT="$(printf '%s' "$RAW_BODY" | jq -r 'if (type == "object" and has("event") and (.event | type == "string")) then .event else empty end')" + FINAL_EVENT_TYPE="${EVENT_TYPE:-${DETECTED_EVENT:-$DEFAULT_EVENT_TYPE}}" + WEBHOOK_SECRET="${WEBHOOK_SECRET:-${GITHUB_APP_WEBHOOK_SECRET:-}}" + if [ -z "$WEBHOOK_SECRET" ]; then + WEBHOOK_SECRET="$(cd "$REPO_ROOT" && load_dotenv_value GITHUB_APP_WEBHOOK_SECRET || true)" + fi + if [ -z "$WEBHOOK_SECRET" ]; then + echo "Set WEBHOOK_SECRET or GITHUB_APP_WEBHOOK_SECRET for GitHub signature verification." >&2 + exit 1 + fi + SIGNATURE="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $NF}')" + + echo "Platform: github" + echo "Delivery ID: $DELIVERY_ID" + echo "Event: $FINAL_EVENT_TYPE" + echo "URL: $WEBHOOK_URL" + echo "Payload source: $PAYLOAD_SOURCE" + echo + + STATUS="$(curl -sS -o "$RESPONSE_FILE" -w "%{http_code}" -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -H "x-github-event: $FINAL_EVENT_TYPE" \ + -H "x-github-delivery: $DELIVERY_ID" \ + -H "x-hub-signature-256: $SIGNATURE" \ + -d "$BODY")" + EXPECTED_PROMPT_TEXT="${EXPECTED_PROMPT_TEXT:-kilo-dev/review-fixture}" else - FINAL_EVENT_TYPE="$DEFAULT_EVENT_TYPE" -fi + WEBHOOK_URL="${WEBHOOK_URL:-http://127.0.0.1:$NEXTJS_PORT/api/webhooks/gitlab}" + FINAL_EVENT_TYPE="${EVENT_TYPE:-Merge Request Hook}" + GITLAB_WEBHOOK_TOKEN="${GITLAB_WEBHOOK_TOKEN:-dev-review-gitlab-webhook-secret}" -# Support envelope payloads like {"event":"...","payload":{...}}. -BODY="$(printf '%s' "$RAW_BODY" | jq -c 'if (type == "object" and has("payload")) then .payload else . end')" + echo "Platform: gitlab" + echo "Delivery ID: $DELIVERY_ID" + echo "Event: $FINAL_EVENT_TYPE" + echo "URL: $WEBHOOK_URL" + echo "Payload source: $PAYLOAD_SOURCE" + echo -SIGNATURE="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $NF}')" + STATUS="$(curl -sS -o "$RESPONSE_FILE" -w "%{http_code}" -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -H "x-gitlab-event: $FINAL_EVENT_TYPE" \ + -H "x-gitlab-event-uuid: $DELIVERY_ID" \ + -H "x-gitlab-token: $GITLAB_WEBHOOK_TOKEN" \ + -d "$BODY")" + EXPECTED_PROMPT_TEXT="${EXPECTED_PROMPT_TEXT:-kilo-dev/gitlab-review-fixture}" +fi -echo "Delivery ID: $DELIVERY_ID" -echo "Event: $FINAL_EVENT_TYPE" -echo "URL: $WEBHOOK_URL" -echo "Payload source:$PAYLOAD_SOURCE" -echo "Signature: $SIGNATURE" -echo -echo "Sending webhook..." +cat "$RESPONSE_FILE" echo +echo "HTTP Status: $STATUS" -curl -s -w "\nHTTP Status: %{http_code}\n" -X POST "$WEBHOOK_URL" \ - -H "Content-Type: application/json" \ - -H "x-github-event: $FINAL_EVENT_TYPE" \ - -H "x-github-delivery: $DELIVERY_ID" \ - -H "x-hub-signature-256: $SIGNATURE" \ - -d "$BODY" +if [ "$STATUS" -lt 200 ] || [ "$STATUS" -ge 300 ]; then + exit 1 +fi + +REVIEW_ID="$(jq -r 'if type == "object" and has("reviewId") then .reviewId else empty end' "$RESPONSE_FILE")" +if [ -n "$REVIEW_ID" ]; then + echo "Review ID: $REVIEW_ID" +fi + +if [ "${VERIFY_FAKE_LLM:-0}" = "1" ]; then + echo "Waiting for fake-llm to observe generated prompt containing: $EXPECTED_PROMPT_TEXT" + DEADLINE=$((SECONDS + ${VERIFY_TIMEOUT_SECONDS:-180})) + while [ "$SECONDS" -lt "$DEADLINE" ]; do + PROMPTS="$(curl -fsS "http://127.0.0.1:$FAKE_LLM_PORT/test/prompts" 2>/dev/null || true)" + if [ -n "$PROMPTS" ] && printf '%s' "$PROMPTS" | jq -e \ + --argjson baseline "$FAKE_LLM_BASELINE_REQ_ID" \ + --arg text "$EXPECTED_PROMPT_TEXT" \ + --arg directive "__fake__:idle" \ + 'any(.prompts[]?; ((.reqId // 0) > $baseline) and (.text | contains($text)) and (.text | contains($directive)))' >/dev/null; then + echo "Verified generated review prompt reached fake-llm." + exit 0 + fi + sleep 2 + done + + echo "Timed out waiting for fake-llm prompt capture." >&2 + exit 1 +fi -echo echo "Done." diff --git a/dev/seed/review/webhook-fixtures.ts b/dev/seed/review/webhook-fixtures.ts new file mode 100644 index 0000000000..d253f9d4f3 --- /dev/null +++ b/dev/seed/review/webhook-fixtures.ts @@ -0,0 +1,279 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { + agent_configs, + cloud_agent_code_reviews, + kilocode_users, + platform_integrations, + webhook_events, +} from '@kilocode/db/schema'; +import type { CodeReviewAgentConfig } from '@kilocode/db/schema-types'; +import { eq } from 'drizzle-orm'; + +import type { SeedResult } from '../index'; +import { getSeedDb } from '../lib/db'; + +export const usage = ''; + +const USER_ID = 'dev-review-webhook-user'; +const USER_EMAIL = 'dev-review-webhook@example.com'; +const USER_NAME = 'Dev Review Webhook'; +const GITHUB_INTEGRATION_ID = '51dfe3d0-65d5-4c19-a2d6-cb37df3c11f1'; +const GITLAB_INTEGRATION_ID = '86a3b6e5-14b7-4f40-97d2-e00f67e741e2'; +const GITHUB_INSTALLATION_ID = '987654321'; +const GITHUB_REPOSITORY_ID = 987654; +const GITHUB_REPO_FULL_NAME = 'kilo-dev/review-fixture'; +const GITHUB_PR_NUMBER = 123; +const GITHUB_HEAD_SHA = '1111111111111111111111111111111111111111'; +const GITHUB_BASE_SHA = '2222222222222222222222222222222222222222'; +const GITLAB_PROJECT_ID = 987654; +const GITLAB_REPO_FULL_NAME = 'kilo-dev/gitlab-review-fixture'; +const GITLAB_MR_IID = 123; +const GITLAB_HEAD_SHA = '3333333333333333333333333333333333333333'; +const GITLAB_WEBHOOK_TOKEN = 'dev-review-gitlab-webhook-secret'; +const FIXTURE_DIR = join(process.cwd(), 'dev', 'review', 'fixtures'); +const GITHUB_FIXTURE_PATH = join(FIXTURE_DIR, 'github-pull-request-opened.json'); +const GITLAB_FIXTURE_PATH = join(FIXTURE_DIR, 'gitlab-merge-request-open.json'); + +function printUsage(): void { + console.log('Usage: pnpm dev:seed review:webhook-fixtures'); + console.log(''); + console.log('Creates local fake GitHub/GitLab integrations and gitignored webhook fixtures.'); +} + +const codeReviewConfig = { + review_style: 'balanced', + focus_areas: ['bugs', 'security', 'testing'], + custom_instructions: + 'Local webhook fixture harness: include __fake__:idle so fake-llm completes without provider CLI calls.', + model_slug: 'kilo/fake-deterministic', + repository_selection_mode: 'all', + disable_review_md: true, + gate_threshold: 'off', +} satisfies CodeReviewAgentConfig; + +const githubFixture = { + action: 'opened', + number: GITHUB_PR_NUMBER, + pull_request: { + number: GITHUB_PR_NUMBER, + title: 'Exercise local code review webhook flow', + body: 'Fixture PR used to test the local Kilo Code review webhook path.', + state: 'open', + draft: false, + html_url: `https://github.com/${GITHUB_REPO_FULL_NAME}/pull/${GITHUB_PR_NUMBER}`, + user: { + id: 583231, + login: 'octocat', + avatar_url: 'https://github.com/images/error/octocat_happy.gif', + }, + head: { + sha: GITHUB_HEAD_SHA, + ref: 'feature/local-review-fixture', + repo: { + full_name: GITHUB_REPO_FULL_NAME, + clone_url: `https://github.com/${GITHUB_REPO_FULL_NAME}.git`, + }, + }, + base: { + sha: GITHUB_BASE_SHA, + ref: 'main', + }, + }, + repository: { + id: GITHUB_REPOSITORY_ID, + name: 'review-fixture', + full_name: GITHUB_REPO_FULL_NAME, + private: false, + owner: { + login: 'kilo-dev', + }, + }, + installation: { + id: Number(GITHUB_INSTALLATION_ID), + }, + sender: { + login: 'octocat', + }, +}; + +const gitlabFixture = { + object_kind: 'merge_request', + event_type: 'merge_request', + user: { + id: 583231, + name: 'Mona Octocat', + username: 'octocat', + }, + project: { + id: GITLAB_PROJECT_ID, + name: 'gitlab-review-fixture', + web_url: `https://gitlab.example.com/${GITLAB_REPO_FULL_NAME}`, + namespace: 'kilo-dev', + path_with_namespace: GITLAB_REPO_FULL_NAME, + default_branch: 'main', + }, + object_attributes: { + id: 456789, + iid: GITLAB_MR_IID, + title: 'Exercise local GitLab review webhook flow', + state: 'opened', + action: 'open', + source_branch: 'feature/local-review-fixture', + target_branch: 'main', + source_project_id: GITLAB_PROJECT_ID, + target_project_id: GITLAB_PROJECT_ID, + author_id: 583231, + created_at: '2026-06-19T00:00:00Z', + updated_at: '2026-06-19T00:00:00Z', + url: `https://gitlab.example.com/${GITLAB_REPO_FULL_NAME}/-/merge_requests/${GITLAB_MR_IID}`, + draft: false, + work_in_progress: false, + last_commit: { + id: GITLAB_HEAD_SHA, + message: 'Add local review fixture change', + }, + }, +}; + +function writeFixtures(): void { + mkdirSync(FIXTURE_DIR, { recursive: true }); + writeFileSync(GITHUB_FIXTURE_PATH, `${JSON.stringify(githubFixture, null, 2)}\n`); + writeFileSync(GITLAB_FIXTURE_PATH, `${JSON.stringify(gitlabFixture, null, 2)}\n`); +} + +export async function run(...args: string[]): Promise { + if (args.includes('--help') || args.includes('-h')) { + printUsage(); + return; + } + if (args.length > 0) { + printUsage(); + throw new Error(`Unexpected arguments: ${args.join(' ')}`); + } + + const db = getSeedDb(); + + await db.delete(webhook_events).where(eq(webhook_events.owned_by_user_id, USER_ID)); + await db + .delete(cloud_agent_code_reviews) + .where(eq(cloud_agent_code_reviews.owned_by_user_id, USER_ID)); + await db.delete(agent_configs).where(eq(agent_configs.owned_by_user_id, USER_ID)); + await db.delete(platform_integrations).where(eq(platform_integrations.owned_by_user_id, USER_ID)); + + await db + .insert(kilocode_users) + .values({ + id: USER_ID, + google_user_email: USER_EMAIL, + google_user_name: USER_NAME, + google_user_image_url: `https://example.com/${USER_ID}.png`, + stripe_customer_id: 'cus_dev_review_webhook', + normalized_email: USER_EMAIL, + email_domain: 'example.com', + has_validation_stytch: true, + customer_source: 'dev-seed', + total_microdollars_acquired: 100_000_000, + } satisfies typeof kilocode_users.$inferInsert) + .onConflictDoUpdate({ + target: kilocode_users.id, + set: { + google_user_email: USER_EMAIL, + google_user_name: USER_NAME, + google_user_image_url: `https://example.com/${USER_ID}.png`, + normalized_email: USER_EMAIL, + email_domain: 'example.com', + has_validation_stytch: true, + customer_source: 'dev-seed', + total_microdollars_acquired: 100_000_000, + }, + }); + + await db.insert(platform_integrations).values([ + { + id: GITHUB_INTEGRATION_ID, + owned_by_user_id: USER_ID, + created_by_user_id: USER_ID, + platform: 'github', + integration_type: 'app', + platform_installation_id: GITHUB_INSTALLATION_ID, + platform_account_id: '100001', + platform_account_login: 'kilo-dev', + repository_access: 'all', + repositories: [ + { + id: GITHUB_REPOSITORY_ID, + name: 'review-fixture', + full_name: GITHUB_REPO_FULL_NAME, + private: false, + }, + ], + metadata: { dev_review_fixture: true }, + kilo_requester_user_id: USER_ID, + platform_requester_account_id: '583231', + integration_status: 'active', + github_app_type: 'standard', + }, + { + id: GITLAB_INTEGRATION_ID, + owned_by_user_id: USER_ID, + created_by_user_id: USER_ID, + platform: 'gitlab', + integration_type: 'oauth', + platform_installation_id: String(GITLAB_PROJECT_ID), + platform_account_id: '583231', + platform_account_login: 'octocat', + repository_access: 'all', + repositories: [ + { + id: GITLAB_PROJECT_ID, + name: 'gitlab-review-fixture', + full_name: GITLAB_REPO_FULL_NAME, + private: false, + }, + ], + metadata: { + dev_review_fixture: true, + webhook_secret: GITLAB_WEBHOOK_TOKEN, + gitlab_instance_url: 'https://gitlab.example.com', + }, + kilo_requester_user_id: USER_ID, + platform_requester_account_id: '583231', + integration_status: 'active', + }, + ] satisfies Array); + + await db.insert(agent_configs).values([ + { + owned_by_user_id: USER_ID, + agent_type: 'code_review', + platform: 'github', + config: codeReviewConfig, + is_enabled: true, + created_by: USER_ID, + }, + { + owned_by_user_id: USER_ID, + agent_type: 'code_review', + platform: 'gitlab', + config: codeReviewConfig, + is_enabled: true, + created_by: USER_ID, + }, + ] satisfies Array); + + writeFixtures(); + + console.log('This fixture represents local fake GitHub/GitLab code-review webhook routing.'); + console.log('Use CODE_REVIEW_LOCAL_FAKE_PROVIDER=1 when starting the code-review dev group.'); + + return { + userId: USER_ID, + githubInstallationId: GITHUB_INSTALLATION_ID, + githubFixturePath: GITHUB_FIXTURE_PATH, + gitlabWebhookToken: GITLAB_WEBHOOK_TOKEN, + gitlabFixturePath: GITLAB_FIXTURE_PATH, + fakeModel: codeReviewConfig.model_slug, + }; +} diff --git a/services/cloud-agent-next/.dev.vars.example b/services/cloud-agent-next/.dev.vars.example index 71c76544be..b3b3ee8eec 100644 --- a/services/cloud-agent-next/.dev.vars.example +++ b/services/cloud-agent-next/.dev.vars.example @@ -31,6 +31,11 @@ KILOCODE_BACKEND_BASE_URL=http://localhost:3000 # @url nextjs/api KILO_OPENROUTER_BASE=http://localhost:3000/api +# Local code-review webhook harness only. Prefer setting +# CODE_REVIEW_LOCAL_FAKE_PROVIDER=1 on `pnpm dev:start code-review`; the dev +# runner passes this flag and routes KILO_OPENROUTER_BASE to fake-llm for you. +KILOCODE_DEV_FAKE_REPOSITORY=0 + # Worker base URL used by the wrapper to connect to /ingest. # Sandbox containers reach the host via `host.docker.internal`. # @url cloud-agent-next diff --git a/services/cloud-agent-next/src/agent-sandbox/cloudflare/cloudflare-agent-sandbox.ts b/services/cloud-agent-next/src/agent-sandbox/cloudflare/cloudflare-agent-sandbox.ts index f0e7ce6b43..2cb2fbc83d 100644 --- a/services/cloud-agent-next/src/agent-sandbox/cloudflare/cloudflare-agent-sandbox.ts +++ b/services/cloud-agent-next/src/agent-sandbox/cloudflare/cloudflare-agent-sandbox.ts @@ -33,6 +33,7 @@ import { } from '../../workspace.js'; import { FAST_SANDBOX_COMMAND_TIMEOUT_MS, + GIT_COMMAND_TIMEOUT_MS, logSandboxOperationTimeout, timedExec, } from '../../sandbox-timeout-logging.js'; @@ -40,6 +41,7 @@ import { SANDBOX_WORKSPACE_PROBE_TIMEOUT_MESSAGE } from '../../sandbox-recovery. import { withTimeout } from '@kilocode/worker-utils'; import { WRAPPER_VERSION } from '../../shared/wrapper-version.js'; import { ExecutionError } from '../../execution/errors.js'; +import { shellQuote } from '../../kilo/utils.js'; import { isSandboxFilesystemUnusableError, SandboxCapacityInspectionError, @@ -48,6 +50,24 @@ import { const PREPARE_WORKSPACE_TIMEOUT_MS = 10 * 60 * 1000; const DEFAULT_STOP_OBSERVATION_DELAYS_MS = [100, 500, 1_000]; +const DEV_FAKE_REPOSITORY_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']); + +function isDevFakeRepositoryEnabled(env: Env, metadata: SessionMetadata): boolean { + const value = env.KILOCODE_DEV_FAKE_REPOSITORY?.trim().toLowerCase(); + return ( + value !== undefined && + DEV_FAKE_REPOSITORY_TRUE_VALUES.has(value) && + metadata.identity.createdOnPlatform === 'code-review' + ); +} + +function devFakeRepositoryLabel(metadata: SessionMetadata): string { + const repository = metadata.repository; + if (!repository) return 'local fake repository'; + if (repository.type === 'github') return repository.repo; + if ('url' in repository) return repository.url; + return 'local fake repository'; +} function withWorkspacePreparationTimeout(operation: Promise, step: string): Promise { return withTimeout( @@ -175,6 +195,51 @@ export class CloudflareAgentSandbox implements AgentSandbox { return this.usesDevcontainerRuntime() ? sessionId : `${sessionId}-bootstrap`; } + private async createDevFakeRepository(sandbox: SandboxInstance, workspacePath: string) { + const originPath = `${workspacePath}.origin.git`; + const upstreamBranch = this.metadata.repository?.upstreamBranch; + const pushUpstreamCommand = upstreamBranch + ? `git push origin HEAD:${shellQuote(upstreamBranch)}` + : 'git push origin HEAD:feature/local-code-review'; + const repoLabel = devFakeRepositoryLabel(this.metadata); + + const command = [ + `rm -rf ${shellQuote(workspacePath)} ${shellQuote(originPath)}`, + `mkdir -p ${shellQuote(workspacePath)}`, + `git init --bare ${shellQuote(originPath)}`, + `git init -b main ${shellQuote(workspacePath)}`, + `cd ${shellQuote(workspacePath)}`, + "git config user.name 'Kilo Code Local'", + "git config user.email 'local-review@kilocode.dev'", + `printf '%s\n' ${shellQuote(`# Local code review fixture\n\nSynthetic repository for ${repoLabel}.`)} > README.md`, + 'git add README.md', + "git commit -m 'Seed local review fixture'", + `git remote add origin ${shellQuote(originPath)}`, + 'git push origin HEAD:main', + 'git checkout -b feature/local-code-review', + `printf '%s\n' ${shellQuote('This line exists so local fake review fixtures have a changed branch.')} > fixture.txt`, + 'git add fixture.txt', + "git commit -m 'Add local review fixture change'", + pushUpstreamCommand, + 'git checkout main', + "printf 'ready\n' > .git/kilo-bootstrap-complete", + ].join(' && '); + + const result = await timedExec(sandbox, command, 'agentSandbox.devFakeRepository', { + timeoutMs: GIT_COMMAND_TIMEOUT_MS, + }); + if (result.exitCode !== 0) { + throw ExecutionError.workspaceSetupFailed( + `Failed to bootstrap local fake repository: ${result.stderr || result.stdout || result.exitCode}`, + undefined, + { + subtype: 'workspace_setup_unknown', + safeFailureMessage: 'Failed to bootstrap local fake repository', + } + ); + } + } + async ensureWrapper(request: EnsureWrapperRequest) { const { plan, prepared } = request; const { sessionId, userId, orgId } = plan.scope; @@ -259,6 +324,9 @@ export class CloudflareAgentSandbox implements AgentSandbox { await checkDiskAndCleanBeforeSetup(sandbox, orgId, userId, sessionId, { inspectContainers: sandboxId.startsWith('dind-'), }); + if (isDevFakeRepositoryEnabled(this.env, this.metadata)) { + await this.createDevFakeRepository(sandbox, prepared.context.workspacePath); + } } request.onProgress?.('kilo_server', 'Starting Kilo...'); const bootstrapSession = await sandbox.createSession({ diff --git a/services/cloud-agent-next/src/persistence/types.ts b/services/cloud-agent-next/src/persistence/types.ts index f89e7196ec..a0b46d385e 100644 --- a/services/cloud-agent-next/src/persistence/types.ts +++ b/services/cloud-agent-next/src/persistence/types.ts @@ -129,6 +129,8 @@ export type PersistenceEnv = { KILOCODE_BACKEND_BASE_URL?: string; /** Base URL override for OpenRouter-compatible Kilo API */ KILO_OPENROUTER_BASE?: string; + /** Local dev only: bootstrap a synthetic git repo instead of cloning provider remotes. */ + KILOCODE_DEV_FAKE_REPOSITORY?: string; /** Kilocode CLI timeout override (seconds) */ CLI_TIMEOUT_SECONDS?: string; /** GitHub App slug for git commit attribution (e.g., 'kiloconnect') */ diff --git a/services/cloud-agent-next/src/session-service.ts b/services/cloud-agent-next/src/session-service.ts index a2773ed44a..eee7342c98 100644 --- a/services/cloud-agent-next/src/session-service.ts +++ b/services/cloud-agent-next/src/session-service.ts @@ -79,6 +79,19 @@ import { const SETUP_COMMAND_TIMEOUT_SECONDS = 300; // 5 minutes const DEFAULT_DENIED_COMMAND_PATTERNS = ['rm -rf', 'sudo rm', 'mkfs', 'dd if=']; +const DEV_FAKE_REPOSITORY_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']); + +function isDevFakeRepositoryEnabled( + env: PersistenceEnv, + metadata: CloudAgentSessionState +): boolean { + const value = env.KILOCODE_DEV_FAKE_REPOSITORY?.trim().toLowerCase(); + return ( + value !== undefined && + DEV_FAKE_REPOSITORY_TRUE_VALUES.has(value) && + metadata.identity.createdOnPlatform === 'code-review' + ); +} function gitLabTokenLookupFailureMessage(reason: string): string { switch (reason) { @@ -1413,6 +1426,27 @@ export class SessionService { let githubCommitCoAuthor: GitAuthorConfig | undefined; let githubFallbackReason: ManagedGitHubFallbackReason | undefined; + if (isDevFakeRepositoryEnabled(env, metadata)) { + if (github) { + logger.withTags({ githubRepo: github.repo }).info('Using local fake GitHub credentials'); + return { + githubToken: 'kilocode-local-fake-github-token', + githubAppType: github.githubAppType ?? 'standard', + githubSource: 'installation', + githubGitAuthor: { name: 'Kilo Code Local', email: 'local-review@kilocode.dev' }, + }; + } + + if (git?.url && repositoryPlatform(metadata) === 'gitlab') { + logger.withTags({ gitUrl: git.url }).info('Using local fake GitLab credentials'); + return { + gitToken: 'kilocode-local-fake-gitlab-token', + gitlabTokenManaged: false, + glabIsOAuth2: false, + }; + } + } + if (github) { const result = await resolveCloudAgentGitHubAuthForRepo(env, { githubRepo: github.repo, @@ -1862,6 +1896,10 @@ export class SessionService { onProgress?.('workspace_setup', 'Setting up workspace…'); await setupWorkspace(sandbox, userId, orgId, sessionId); + if (isDevFakeRepositoryEnabled(env, metadata)) { + await this.createDevFakeRepository(sandbox, workspacePath, metadata); + } + const session = await this.buildSessionForContext( sandbox, context, @@ -1875,7 +1913,7 @@ export class SessionService { let dockerEnv: Record | undefined; try { onProgress?.('cloning', 'Cloning repository…'); - await this.cloneRepository(session, workspacePath, metadata, resolvedTokens); + await this.cloneRepository(session, workspacePath, env, metadata, resolvedTokens); onProgress?.('branch', 'Setting up branch…'); await this.prepareBranch(session, workspacePath, branchName, metadata); @@ -2024,12 +2062,73 @@ export class SessionService { } } + private async createDevFakeRepository( + executor: SandboxInstance | ExecutionSession, + workspacePath: string, + metadata: CloudAgentSessionState + ): Promise { + const originPath = `${workspacePath}.origin.git`; + const upstreamBranch = metadata.repository?.upstreamBranch; + const pushUpstreamCommand = upstreamBranch + ? `git push origin HEAD:${shellQuote(upstreamBranch)}` + : 'git push origin HEAD:feature/local-code-review'; + const repoLabel = + githubRepository(metadata)?.repo ?? gitRepository(metadata)?.url ?? 'local fake repository'; + + logger + .withTags({ workspacePath, originPath, upstreamBranch }) + .info('Bootstrapping local fake repository for code-review dev session'); + + const command = [ + `rm -rf ${shellQuote(workspacePath)} ${shellQuote(originPath)}`, + `mkdir -p ${shellQuote(workspacePath)}`, + `git init --bare ${shellQuote(originPath)}`, + `git init -b main ${shellQuote(workspacePath)}`, + `cd ${shellQuote(workspacePath)}`, + "git config user.name 'Kilo Code Local'", + "git config user.email 'local-review@kilocode.dev'", + `printf '%s\n' ${shellQuote(`# Local code review fixture\n\nSynthetic repository for ${repoLabel}.`)} > README.md`, + 'git add README.md', + "git commit -m 'Seed local review fixture'", + `git remote add origin ${shellQuote(originPath)}`, + 'git push origin HEAD:main', + 'git checkout -b feature/local-code-review', + `printf '%s\n' ${shellQuote('This line exists so local fake review fixtures have a changed branch.')} > fixture.txt`, + 'git add fixture.txt', + "git commit -m 'Add local review fixture change'", + pushUpstreamCommand, + 'git checkout main', + "printf 'ready\n' > .git/kilo-bootstrap-complete", + ].join(' && '); + + const result = await timedExec( + executor, + command, + 'session.prepareWorkspace.devFakeRepository', + { + timeoutMs: GIT_COMMAND_TIMEOUT_MS, + } + ); + if (result.exitCode !== 0) { + throw new Error( + `Failed to bootstrap local fake repository: ${result.stderr || result.stdout || result.exitCode}` + ); + } + } + private async cloneRepository( session: ExecutionSession, workspacePath: string, + env: PersistenceEnv, metadata: CloudAgentSessionState, tokens: ResolvedWorkspaceTokens ): Promise { + if (isDevFakeRepositoryEnabled(env, metadata)) { + if (await this.workspaceHasGit(session, workspacePath)) return; + await this.createDevFakeRepository(session, workspacePath, metadata); + return; + } + const cloneOptions = repositoryShallow(metadata) ? { shallow: true } : undefined; const git = gitRepository(metadata); if (git) { diff --git a/services/cloud-agent-next/src/types.ts b/services/cloud-agent-next/src/types.ts index ca6125f5f8..24fc5d492a 100644 --- a/services/cloud-agent-next/src/types.ts +++ b/services/cloud-agent-next/src/types.ts @@ -221,6 +221,8 @@ export type Env = { KILOCODE_BACKEND_BASE_URL?: string; /** Base URL override for OpenRouter-compatible Kilo API */ KILO_OPENROUTER_BASE?: string; + /** Local dev only: bootstrap a synthetic git repo instead of cloning provider remotes. */ + KILOCODE_DEV_FAKE_REPOSITORY?: string; /** Kilocode CLI timeout override (seconds) */ CLI_TIMEOUT_SECONDS?: string; /** Reaper interval override (ms) */ diff --git a/services/cloud-agent-next/test/e2e/fake-llm-server.ts b/services/cloud-agent-next/test/e2e/fake-llm-server.ts index 94393a9d74..26e92b64dc 100644 --- a/services/cloud-agent-next/test/e2e/fake-llm-server.ts +++ b/services/cloud-agent-next/test/e2e/fake-llm-server.ts @@ -61,6 +61,17 @@ type ServerState = { nextRequestId: number; /** Count of dispatched completions, exposed for fail-fast scenario assertions. */ chatCompletionRequests: number; + /** Recent user prompts observed by chat.completions, for local harness assertions. */ + capturedPrompts: CapturedPrompt[]; +}; + +type CapturedPrompt = { + reqId: number; + model?: string; + scenario?: string; + text: string; + messageCount: number; + recordedAt: string; }; type LogFields = Record; @@ -509,6 +520,16 @@ async function handleChatCompletions( const prompt = extractLastUserMessageText(body); const directive = parseDirective(prompt); + state.capturedPrompts.push({ + reqId: reqLogId, + model: bodyModel, + scenario: directive?.scenario, + text: prompt, + messageCount, + recordedAt: new Date().toISOString(), + }); + state.capturedPrompts = state.capturedPrompts.slice(-20); + logEvent('request.start', { reqId: reqLogId, route: 'POST /api/openrouter/chat/completions', @@ -651,6 +672,16 @@ function handleRequestCounts(res: ServerResponse, state: ServerState): void { res.end(JSON.stringify({ chatCompletions: state.chatCompletionRequests })); } +function handlePrompts(res: ServerResponse, state: ServerState): void { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + count: state.capturedPrompts.length, + prompts: state.capturedPrompts, + }) + ); +} + /** * Snapshot of all currently parked gate waiters, grouped by tag. Tests use * this after expected completions to assert the fake server has no stale @@ -682,6 +713,7 @@ export async function startFakeLlmServer(opts?: { liveResponses: new Set(), nextRequestId: 0, chatCompletionRequests: 0, + capturedPrompts: [], }; const sockets = new Set(); @@ -736,6 +768,10 @@ export async function startFakeLlmServer(opts?: { handleRequestCounts(res, state); return; } + if (route === 'GET /test/prompts') { + handlePrompts(res, state); + return; + } logEvent('request.unknown', { method: req.method, path: url.pathname }); res.writeHead(404, { 'Content-Type': 'application/json' });