Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,8 @@ function makeReview(overrides: Partial<CloudAgentCodeReview> = {}): CloudAgentCo
previous_summary_body: null,
previous_summary_head_sha: null,
manual_config: null,
review_type: 'standard',
trigger_source: null,
model: null,
total_tokens_in: null,
total_tokens_out: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ describe('POST /api/webhooks/bitbucket/[integrationId]', () => {
pr_number: PULL_REQUEST_ID,
head_sha: DEFAULT_HEAD_SHA,
status: 'pending',
trigger_source: 'webhook',
})
);
const events = await organizationWebhookEvents();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
headRef: pullRequest.source.branch,
headSha: pullRequest.source.sha,
platform: 'bitbucket',
triggerSource: 'webhook',
});
await completeBitbucketWebhookEventInTransaction(tx, event.id, observation);
return {
Expand Down
122 changes: 122 additions & 0 deletions apps/web/src/lib/code-reviews/core/council-entitlement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { TRPCError } from '@trpc/server';

const mockGetOrganizationById = jest.fn();
const mockGetMostRecentSeatPurchase = jest.fn();
const mockIsLocalCodeReviewDevelopmentEnabled = jest.fn();

jest.mock('@/lib/organizations/organizations', () => ({
getOrganizationById: (...args: unknown[]) => mockGetOrganizationById(...args),
}));
jest.mock('@/lib/organizations/organization-seats', () => ({
getMostRecentSeatPurchase: (...args: unknown[]) => mockGetMostRecentSeatPurchase(...args),
}));
jest.mock('@/lib/config.server', () => ({
isLocalCodeReviewDevelopmentEnabled: () => mockIsLocalCodeReviewDevelopmentEnabled(),
}));

// Real classifyOrganizationEntitlement is used (not mocked) so the tests exercise the
// canonical active-entitlement logic layered on top of the plan tier.
import {
isCouncilEntitledForOrganization,
isCouncilEntitledForOwner,
assertCouncilCreationAllowed,
} from './council-entitlement';

const DAY_MS = 24 * 60 * 60 * 1000;
const hardExpiredTrialEnd = new Date(Date.now() - 5 * DAY_MS).toISOString();

function enterpriseOrg(overrides: Record<string, unknown> = {}) {
return {
id: 'org-1',
plan: 'enterprise',
created_at: new Date(Date.now() - 60 * DAY_MS).toISOString(),
free_trial_end_at: hardExpiredTrialEnd,
require_seats: true,
settings: {},
...overrides,
};
}

beforeEach(() => {
jest.clearAllMocks();
mockIsLocalCodeReviewDevelopmentEnabled.mockReturnValue(false);
mockGetMostRecentSeatPurchase.mockResolvedValue(null);
});

describe('isCouncilEntitledForOrganization', () => {
it('returns true for an enterprise org with an active paid subscription', async () => {
mockGetOrganizationById.mockResolvedValue(enterpriseOrg());
mockGetMostRecentSeatPurchase.mockResolvedValue({ subscription_status: 'active' });

await expect(isCouncilEntitledForOrganization('org-1')).resolves.toBe(true);
});

it('returns false for a lapsed enterprise org (plan still enterprise, subscription ended, trial hard-expired)', async () => {
// Regression: plan is NOT downgraded on cancellation, so plan alone must not grant council.
mockGetOrganizationById.mockResolvedValue(enterpriseOrg());
mockGetMostRecentSeatPurchase.mockResolvedValue({ subscription_status: 'ended' });

await expect(isCouncilEntitledForOrganization('org-1')).resolves.toBe(false);
});

it('returns false for a non-enterprise plan without checking subscription status', async () => {
mockGetOrganizationById.mockResolvedValue(enterpriseOrg({ plan: 'teams' }));

await expect(isCouncilEntitledForOrganization('org-1')).resolves.toBe(false);
expect(mockGetMostRecentSeatPurchase).not.toHaveBeenCalled();
});

it('returns false when the organization does not exist or id is missing', async () => {
mockGetOrganizationById.mockResolvedValue(null);
await expect(isCouncilEntitledForOrganization('org-1')).resolves.toBe(false);
await expect(isCouncilEntitledForOrganization(null)).resolves.toBe(false);
expect(mockGetOrganizationById).toHaveBeenCalledTimes(1);
});
});

describe('isCouncilEntitledForOwner', () => {
it('is never entitled for personal (user) owners', async () => {
await expect(isCouncilEntitledForOwner({ type: 'user', id: 'u1', userId: 'u1' })).resolves.toBe(
false
);
expect(mockGetOrganizationById).not.toHaveBeenCalled();
});
});

describe('assertCouncilCreationAllowed', () => {
const orgOwner = { type: 'org', id: 'org-1', userId: 'u1' } as const;

it('is a no-op for non-council reviews', async () => {
await expect(
assertCouncilCreationAllowed({ owner: orgOwner, reviewType: 'standard' })
).resolves.toBeUndefined();
expect(mockGetOrganizationById).not.toHaveBeenCalled();
});

it('throws FORBIDDEN when a council review is requested without entitlement', async () => {
mockGetOrganizationById.mockResolvedValue(enterpriseOrg());
mockGetMostRecentSeatPurchase.mockResolvedValue({ subscription_status: 'ended' });

await expect(
assertCouncilCreationAllowed({ owner: orgOwner, reviewType: 'council' })
).rejects.toThrow(TRPCError);
});

it('allows a council review for an entitled enterprise org', async () => {
mockGetOrganizationById.mockResolvedValue(enterpriseOrg());
mockGetMostRecentSeatPurchase.mockResolvedValue({ subscription_status: 'active' });

await expect(
assertCouncilCreationAllowed({ owner: orgOwner, reviewType: 'council' })
).resolves.toBeUndefined();
});

it('bypasses entitlement in local development', async () => {
mockIsLocalCodeReviewDevelopmentEnabled.mockReturnValue(true);

await expect(
assertCouncilCreationAllowed({ owner: orgOwner, reviewType: 'council' })
).resolves.toBeUndefined();
expect(mockGetOrganizationById).not.toHaveBeenCalled();
});
});
76 changes: 76 additions & 0 deletions apps/web/src/lib/code-reviews/core/council-entitlement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import 'server-only';

import { TRPCError } from '@trpc/server';
import { getOrganizationById } from '@/lib/organizations/organizations';
import { getMostRecentSeatPurchase } from '@/lib/organizations/organization-seats';
import { classifyOrganizationEntitlement } from '@/lib/organizations/trial-utils';
import { isLocalCodeReviewDevelopmentEnabled } from '@/lib/config.server';
import type { CodeReviewType } from '@kilocode/db/schema-types';
import type { Owner } from './schemas';

/**
* Which plan tier grants council. Council is enterprise-only.
*
* NOTE: this is only the tier label. `organizations.plan` stays `'enterprise'` even
* after an enterprise subscription ends (it is not downgraded on cancellation), so plan
* alone is NOT proof of active entitlement. Active-subscription/trial status is layered
* on separately in `isCouncilEntitledForOrganization`. Staged rollout (which entitled
* users see council yet) is a separate client-side PostHog concern handled elsewhere.
*/
export function isCouncilEntitledPlan(plan: string | null | undefined): boolean {
return plan === 'enterprise';
}

/**
* Council reviews are an enterprise-only paid feature. An organization is entitled only
* when it is on the enterprise tier AND currently has active entitlement (active paid
* subscription or non-expired trial). This mirrors the canonical entitlement check used
* by `requireActiveSubscriptionOrTrial`, so a lapsed enterprise org (plan still
* `'enterprise'`, seat purchase `ended`, trial hard-expired) cannot create paid council
* reviews. Personal owners are never entitled.
*/
export async function isCouncilEntitledForOrganization(
organizationId: string | null | undefined
): Promise<boolean> {
if (!organizationId) return false;
const organization = await getOrganizationById(organizationId);
if (!organization || !isCouncilEntitledPlan(organization.plan)) return false;

const latestPurchase = await getMostRecentSeatPurchase(organizationId);
const classification = classifyOrganizationEntitlement({
organization,
latestSeatPurchaseStatus: latestPurchase?.subscription_status ?? null,
now: new Date(),
});
return classification.hasEntitlement;
}

export async function isCouncilEntitledForOwner(owner: Owner): Promise<boolean> {
return owner.type === 'org' ? isCouncilEntitledForOrganization(owner.id) : false;
}

/**
* Single enforcement point for council entitlement, invoked at the review-creation
* boundary. Any path that persists a `council` review (manual or automated) passes
* through here, so a non-entitled owner can never create one. Local dev bypasses so
* the feature can be exercised without an enterprise org.
*
* Throws FORBIDDEN when a council review is requested without entitlement.
*/
export async function assertCouncilCreationAllowed(params: {
owner: Owner;
reviewType?: CodeReviewType;
}): Promise<void> {
if (params.reviewType !== 'council') return;
if (isLocalCodeReviewDevelopmentEnabled()) return;
if (await isCouncilEntitledForOwner(params.owner)) return;
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Council reviews require an Enterprise plan.',
});
}

// NOTE: a config-save guard (`assertCouncilConfigAllowed`) will be added alongside the
// council config-save path (PR #5) that actually accepts a `council` config, so it lands
// with its first caller rather than as an unreferenced export. The creation-boundary
// guard above already prevents a non-entitled owner from *running* a council review.
11 changes: 10 additions & 1 deletion apps/web/src/lib/code-reviews/core/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@

import * as z from 'zod';
import type { CloudAgentCodeReview } from '@kilocode/db/schema';
import { CODE_REVIEW_PLATFORMS, ManualCodeReviewConfigSchema } from '@kilocode/db/schema-types';
import {
CODE_REVIEW_PLATFORMS,
ManualCodeReviewConfigSchema,
CodeReviewTypeSchema,
CodeReviewTriggerSourceSchema,
} from '@kilocode/db/schema-types';
import { CODE_REVIEW_STATUSES } from '@kilocode/app-shared/code-review';
import { CodeReviewAgentConfigSchema } from '@/lib/agent-config/core/types';

Expand Down Expand Up @@ -121,6 +126,10 @@ export const CreateReviewParamsSchema = z.object({
platform: CodeReviewPlatformSchema.default('github'),
platformProjectId: z.number().int().positive().optional(),
manualConfig: ManualCodeReviewConfigSchema.nullable().optional(),
// Review type for this run; unset defaults to standard at the insert boundary.
reviewType: CodeReviewTypeSchema.optional(),
// Origin of the run (manual vs webhook); optional for legacy/unknown callers.
triggerSource: CodeReviewTriggerSourceSchema.optional(),
});

/**
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/lib/code-reviews/db/code-reviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { eq, and, asc, desc, count, ne, inArray, sql, sum, gte, lte, isNull } from 'drizzle-orm';
import { captureException } from '@sentry/nextjs';
import { CreateReviewParamsSchema } from '../core';
import { assertCouncilCreationAllowed } from '../core/council-entitlement';
import type {
CodeReviewPlatform,
CreateReviewParams,
Expand Down Expand Up @@ -204,6 +205,8 @@ function codeReviewInsertValues(
platform: params.platform ?? 'github',
platform_project_id: params.platformProjectId ?? null,
manual_config: params.manualConfig ?? null,
review_type: params.reviewType ?? 'standard',
trigger_source: params.triggerSource ?? null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: New reviews discard their known trigger provenance

Every current production caller omits triggerSource, including manual jobs and the GitHub, GitLab, and Bitbucket webhook paths, so this fallback writes NULL for all new reviews. That makes known manual/webhook origins indistinguishable from legacy rows and leaves the new field unpopulated; have those creation paths pass manual or webhook explicitly.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 34a03ae. All five code-review creation paths now pass an explicit triggerSource, so new rows record their origin instead of writing NULL:

  • lib/code-reviews/manual-code-review-jobs.tsmanual
  • lib/integrations/platforms/github/webhook-handlers/pull-request-handler.tswebhook
  • lib/integrations/platforms/gitlab/webhook-handlers/merge-request-handler.tswebhook
  • lib/integrations/platforms/bitbucket/manual-code-review-trigger.tsmanual
  • app/api/webhooks/bitbucket/[integrationId]/route.tswebhook

The ?? null fallback in codeReviewInsertValues stays only as a safety net for any future/unknown caller. Regression coverage added: the GitHub and GitLab handler tests assert createCodeReview is called with triggerSource: 'webhook', and the Bitbucket webhook route test asserts the persisted row has trigger_source: 'webhook'. The latest review reflects this file as 0 issues.

agent_version: 'v2',
status: 'pending',
};
Expand All @@ -216,6 +219,7 @@ function codeReviewInsertValues(
export async function createCodeReview(params: CreateReviewParams): Promise<string> {
try {
CreateReviewParamsSchema.parse(params);
await assertCouncilCreationAllowed({ owner: params.owner, reviewType: params.reviewType });
const [review] = await db
.insert(cloud_agent_code_reviews)
.values(codeReviewInsertValues(params))
Expand Down Expand Up @@ -1448,6 +1452,7 @@ export async function createCodeReviewIfAbsentInTransaction(
params: CreateReviewParams
): Promise<{ reviewId: string; created: boolean }> {
CreateReviewParamsSchema.parse(params);
await assertCouncilCreationAllowed({ owner: params.owner, reviewType: params.reviewType });
const [created] = await tx
.insert(cloud_agent_code_reviews)
.values(codeReviewInsertValues(params))
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/code-reviews/manual-code-review-jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ export async function createManualCodeReviewJob(params: {
platform: source.platform,
platformProjectId: source.platformProjectId,
manualConfig,
triggerSource: 'manual',
});

logExceptInTest('[manual-code-review-job] Created manual Code Reviewer job', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,7 @@ export async function triggerManualBitbucketCodeReview(input: {
headRef: pullRequest.source.branch,
headSha: pullRequest.source.sha,
platform: 'bitbucket',
triggerSource: 'manual',
});
return {
cancelledReviews,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@ describe('handlePullRequest', () => {
platform: 'github',
platformIntegrationId: '8b2ff443-8396-4b07-99ae-7015789da7dd',
repoFullName: 'acme/widgets',
triggerSource: 'webhook',
})
);
expect(mockTryDispatchPendingReviews).toHaveBeenCalledTimes(1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ export async function handlePullRequestCodeReview(
headRef: checkoutRef.checkoutRef,
headSha: pull_request.head.sha,
platform: 'github',
triggerSource: 'webhook',
});

logExceptInTest(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ describe('handleMergeRequestCodeReview', () => {
platformIntegrationId: '8b2ff443-8396-4b07-99ae-7015789da7dd',
repoFullName: 'acme/widgets',
platformProjectId: 123,
triggerSource: 'webhook',
})
);
expect(mockTryDispatchPendingReviews).toHaveBeenCalledTimes(1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ export async function handleMergeRequestCodeReview(
headSha,
platform: PLATFORM.GITLAB,
platformProjectId: project.id,
triggerSource: 'webhook',
});

logExceptInTest(`Created code review ${reviewId} for ${project.path_with_namespace}!${mr.iid}`);
Expand Down
2 changes: 2 additions & 0 deletions packages/db/src/migrations/0183_great_mercury.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE "cloud_agent_code_reviews" ADD COLUMN "review_type" text DEFAULT 'standard' NOT NULL;--> statement-breakpoint

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

**[WARNING]: Migration can block the live reviews table

Each ALTER TABLE needs an ACCESS EXCLUSIVE lock on the populated cloud_agent_code_reviews table. The constant default avoids a table rewrite on modern PostgreSQL, but the migration can still queue behind long-running traffic and then block all reads and writes when it acquires the lock. Use the repository's deployment-safe lock strategy, such as a short lock_timeout with retry or an explicitly controlled maintenance window.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not addressing this one, by design. A few reasons:

  1. No such convention exists in this repo. None of the 97+ ADD COLUMN migrations under packages/db/src/migrations/ use lock_timeout, SET LOCAL, or a retry wrapper — including main's own same-day 0182_powerful_blacklash.sql. There is no "repository deployment-safe lock strategy" to adopt here.

  2. It would violate a hard repo rule. packages/db/AGENTS.md: "Do not hand-write or edit generated DDL, snapshots, or journal entries." This SQL is Drizzle-generated; adding lock pragmas means editing generated DDL. Only intentional data backfills may be appended after the generated DDL.

  3. Not specific to this change. On our PostgreSQL, an ADD COLUMN with a constant default is a metadata-only operation, so the ACCESS EXCLUSIVE lock is held briefly. The 'queue behind a long-running transaction' risk applies identically to every ADD COLUMN migration in the repo; it's a fleet-wide operational/tooling posture, not something to bolt onto one feature migration.

If we want lock-safe migrations, that's a separate change to the migration tooling/convention that should apply to all migrations, tracked on its own.

ALTER TABLE "cloud_agent_code_reviews" ADD COLUMN "trigger_source" text;
Loading