From 8d81311a81601d907309c5c1eca22776ef91857e Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Sun, 22 Feb 2026 19:08:27 +0000 Subject: [PATCH] feat(review): add configurable review trigger modes (ownPrsOnly, externalPrs, onReviewRequested) --- CLAUDE.md | 51 +++++ .../dashboard/projects/review-trigger-set.ts | 86 +++++++ src/config/triggerConfig.ts | 61 ++++- src/triggers/github/check-suite-success.ts | 26 ++- src/triggers/github/review-requested.ts | 5 +- tests/unit/config/triggerConfig.test.ts | 93 ++++++++ .../unit/triggers/check-suite-success.test.ts | 210 ++++++++++++++++++ tests/unit/triggers/review-requested.test.ts | 72 +++++- web/src/lib/trigger-agent-mapping.ts | 24 +- 9 files changed, 610 insertions(+), 18 deletions(-) create mode 100644 src/cli/dashboard/projects/review-trigger-set.ts diff --git a/CLAUDE.md b/CLAUDE.md index c91b116e..6c08a6bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -189,6 +189,57 @@ const openrouterKey = await getOrgCredential(projectId, 'OPENROUTER_API_KEY'); Role definitions and env-var-key mappings are in `src/config/integrationRoles.ts`. +### Review Agent Trigger Modes + +The review agent supports three independent trigger modes via the `reviewTrigger` config in the SCM integration triggers. **All modes default to `false`** — existing behavior is preserved via a legacy fallback. + +| Mode | Description | +|------|-------------| +| `ownPrsOnly` | Trigger review when CI passes on PRs authored by the **implementer** persona | +| `externalPrs` | Trigger review when CI passes on PRs authored by **anyone** (including external contributors) | +| `onReviewRequested` | Trigger review when a CASCADE persona is **explicitly requested** as reviewer | + +#### Setting via CLI + +```bash +# Enable review for implementer PRs only (most common) +cascade projects review-trigger-set --own-prs-only + +# Enable review for external contributor PRs +cascade projects review-trigger-set --external-prs + +# Enable both CI-triggered modes +cascade projects review-trigger-set --own-prs-only --external-prs + +# Enable review when explicitly requested +cascade projects review-trigger-set --on-review-requested + +# Disable a mode +cascade projects review-trigger-set --no-own-prs-only +``` + +#### Setting via Dashboard + +In the **Agent Configs** tab, the `review` agent section shows three toggles under the SCM integration: +- **Own PRs Only** — CI-triggered review for implementer-authored PRs +- **External PRs** — CI-triggered review for all other PR authors +- **On Review Requested** — review triggered when a persona is explicitly requested + +#### Direct JSON Config + +```bash +cascade projects integration-set \ + --category scm --provider github --config '{}' \ + --triggers '{"reviewTrigger":{"ownPrsOnly":true,"externalPrs":false,"onReviewRequested":true}}' +``` + +#### Backward Compatibility + +When `reviewTrigger` is absent, the system falls back to legacy booleans: +- `checkSuiteSuccess` → `ownPrsOnly` (default `true` for existing projects) +- `reviewRequested` → `onReviewRequested` (default `false`) +- `externalPrs` always `false` in legacy mode (no legacy equivalent) + ## Claude Code Backend CASCADE supports using Claude Code SDK as an alternative agent backend. Configure per-project: diff --git a/src/cli/dashboard/projects/review-trigger-set.ts b/src/cli/dashboard/projects/review-trigger-set.ts new file mode 100644 index 00000000..b8e016ea --- /dev/null +++ b/src/cli/dashboard/projects/review-trigger-set.ts @@ -0,0 +1,86 @@ +import { Args, Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +/** + * CLI command for configuring the review agent's trigger modes. + * + * Usage: + * cascade projects review-trigger-set [--own-prs-only] [--external-prs] [--on-review-requested] + * + * At least one flag must be provided. Pass `--no-` to disable a mode. + * Uses the `projects.integrations.updateTriggers` tRPC endpoint, updating the + * `reviewTrigger` nested object in the project's SCM integration triggers. + */ +export default class ProjectsReviewTriggerSet extends DashboardCommand { + static override description = + 'Configure review trigger modes for a project (which PRs the review agent should review).'; + + static override aliases = ['projects:review-trigger-set']; + + static override args = { + id: Args.string({ description: 'Project ID', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + 'own-prs-only': Flags.boolean({ + description: + 'Enable review agent for PRs authored by the implementer persona (after CI passes).', + allowNo: true, + default: undefined, + }), + 'external-prs': Flags.boolean({ + description: + 'Enable review agent for PRs authored by anyone outside the CASCADE personas (after CI passes).', + allowNo: true, + default: undefined, + }), + 'on-review-requested': Flags.boolean({ + description: + 'Enable review agent when a CASCADE persona is explicitly requested as reviewer.', + allowNo: true, + default: undefined, + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(ProjectsReviewTriggerSet); + + const ownPrsOnly = flags['own-prs-only']; + const externalPrs = flags['external-prs']; + const onReviewRequested = flags['on-review-requested']; + + if (ownPrsOnly === undefined && externalPrs === undefined && onReviewRequested === undefined) { + this.error( + 'At least one flag must be provided: --own-prs-only, --external-prs, --on-review-requested (use --no- to disable).', + ); + } + + // Build the nested reviewTrigger object with only the provided flags + const reviewTrigger: Record = {}; + if (ownPrsOnly !== undefined) reviewTrigger.ownPrsOnly = ownPrsOnly; + if (externalPrs !== undefined) reviewTrigger.externalPrs = externalPrs; + if (onReviewRequested !== undefined) reviewTrigger.onReviewRequested = onReviewRequested; + + try { + await this.client.projects.integrations.updateTriggers.mutate({ + projectId: args.id, + category: 'scm', + triggers: { reviewTrigger }, + }); + + if (flags.json) { + this.outputJson({ ok: true, reviewTrigger }); + return; + } + + const lines: string[] = [`Review trigger modes updated for project: ${args.id}`]; + if (ownPrsOnly !== undefined) lines.push(` ownPrsOnly: ${ownPrsOnly}`); + if (externalPrs !== undefined) lines.push(` externalPrs: ${externalPrs}`); + if (onReviewRequested !== undefined) lines.push(` onReviewRequested: ${onReviewRequested}`); + this.log(lines.join('\n')); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/config/triggerConfig.ts b/src/config/triggerConfig.ts index b28df502..e9a1f0a8 100644 --- a/src/config/triggerConfig.ts +++ b/src/config/triggerConfig.ts @@ -43,6 +43,21 @@ export const JiraTriggerConfigSchema = z.object({ commentMention: z.boolean().default(true), }); +/** + * Structured review trigger configuration with three independent modes. + * All modes default to `false` (safe default — users must explicitly opt in). + */ +export const ReviewTriggerConfigSchema = z.object({ + /** Trigger review for PRs authored by the implementer persona. */ + ownPrsOnly: z.boolean().default(false), + /** Trigger review for PRs authored by anyone (not just the implementer). */ + externalPrs: z.boolean().default(false), + /** Trigger review when a CASCADE persona is explicitly requested as reviewer. */ + onReviewRequested: z.boolean().default(false), +}); + +export type ReviewTriggerConfig = z.infer; + /** * Trigger configuration for GitHub integrations. * Existing triggers default to `true`; new triggers (`reviewRequested`, `prOpened`) default to `false`. @@ -54,16 +69,58 @@ export const GitHubTriggerConfigSchema = z.object({ prCommentMention: z.boolean().default(true), prReadyToMerge: z.boolean().default(true), prMerged: z.boolean().default(true), - /** New trigger: fires review agent when review is requested from a CASCADE persona. Default false (opt-in). */ + /** Legacy trigger: fires review agent when review is requested from a CASCADE persona. Default false (opt-in). */ reviewRequested: z.boolean().default(false), /** PR opened trigger. Default false (disabled until reviewed). */ prOpened: z.boolean().default(false), + /** + * Structured review trigger config with three independent modes. + * When present, takes precedence over the legacy `reviewRequested` / `checkSuiteSuccess` booleans. + */ + reviewTrigger: ReviewTriggerConfigSchema.optional(), }); export type TrelloTriggerConfig = z.infer; export type JiraTriggerConfig = z.infer; export type GitHubTriggerConfig = z.infer; +// ============================================================================ +// Review Trigger Resolution +// ============================================================================ + +/** + * Resolve the structured review trigger config from GitHub trigger config. + * + * Precedence: + * 1. `reviewTrigger` object (new structured config) — wins when present + * 2. Legacy booleans: `checkSuiteSuccess` → `ownPrsOnly`, `reviewRequested` → `onReviewRequested` + * 3. Bare defaults (no config) — all modes false + * + * This helper is the single source of truth for determining which review trigger modes are active. + */ +export function resolveReviewTriggerConfig( + config: Partial | undefined, +): ReviewTriggerConfig { + // New structured config wins when present + if (config?.reviewTrigger !== undefined) { + return { + ownPrsOnly: config.reviewTrigger.ownPrsOnly ?? false, + externalPrs: config.reviewTrigger.externalPrs ?? false, + onReviewRequested: config.reviewTrigger.onReviewRequested ?? false, + }; + } + + // Legacy fallback: map old boolean flags to structured modes + const legacyOwnPrsOnly = config?.checkSuiteSuccess ?? true; // existing default was true + const legacyOnReviewRequested = config?.reviewRequested ?? false; + + return { + ownPrsOnly: legacyOwnPrsOnly, + externalPrs: false, // no legacy equivalent — always false + onReviewRequested: legacyOnReviewRequested, + }; +} + // ============================================================================ // Helpers // ============================================================================ @@ -151,5 +208,7 @@ export function resolveGitHubTriggerEnabled( if (key === 'reviewRequested' || key === 'prOpened') return false; return true; } + // reviewTrigger is an object, not a boolean — skip it in this function + if (typeof value !== 'boolean') return true; return value; } diff --git a/src/triggers/github/check-suite-success.ts b/src/triggers/github/check-suite-success.ts index 566825ca..fb989719 100644 --- a/src/triggers/github/check-suite-success.ts +++ b/src/triggers/github/check-suite-success.ts @@ -1,4 +1,4 @@ -import { resolveGitHubTriggerEnabled } from '../../config/triggerConfig.js'; +import { resolveReviewTriggerConfig } from '../../config/triggerConfig.js'; import { type CheckSuiteStatus, githubClient } from '../../github/client.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; @@ -66,8 +66,9 @@ export class CheckSuiteSuccessTrigger implements TriggerHandler { if (ctx.source !== 'github') return false; if (!isGitHubCheckSuitePayload(ctx.payload)) return false; - // Check trigger config — default enabled for backward compatibility - if (!resolveGitHubTriggerEnabled(ctx.project.github?.triggers, 'checkSuiteSuccess')) { + // Check trigger config — at least one CI-based review mode must be active + const reviewConfig = resolveReviewTriggerConfig(ctx.project.github?.triggers); + if (!reviewConfig.ownPrsOnly && !reviewConfig.externalPrs) { return false; } @@ -99,13 +100,24 @@ export class CheckSuiteSuccessTrigger implements TriggerHandler { // Fetch PR details const prDetails = await githubClient.getPR(owner, repo, prNumber); - // Gate on PR author being the implementer persona + // Gate on PR author based on configured review trigger modes if (!ctx.personaIdentities) return null; const implLogin = ctx.personaIdentities.implementer; - if (prDetails.user.login !== implLogin && prDetails.user.login !== `${implLogin}[bot]`) { - logger.info('PR not authored by implementer persona, skipping', { + const isImplementerPR = + prDetails.user.login === implLogin || prDetails.user.login === `${implLogin}[bot]`; + + const reviewConfig = resolveReviewTriggerConfig(ctx.project.github?.triggers); + const shouldTrigger = + (reviewConfig.ownPrsOnly && isImplementerPR) || + (reviewConfig.externalPrs && !isImplementerPR); + + if (!shouldTrigger) { + logger.info('PR author does not match any enabled review trigger mode, skipping', { prNumber, prAuthor: prDetails.user.login, + isImplementerPR, + ownPrsOnly: reviewConfig.ownPrsOnly, + externalPrs: reviewConfig.externalPrs, }); return null; } @@ -177,7 +189,7 @@ export class CheckSuiteSuccessTrigger implements TriggerHandler { return null; } - logger.info('All CI checks passed on implementer PR - triggering review', { + logger.info('All CI checks passed - triggering review', { prNumber, workItemId, headSha, diff --git a/src/triggers/github/review-requested.ts b/src/triggers/github/review-requested.ts index 14d4524c..af7fc097 100644 --- a/src/triggers/github/review-requested.ts +++ b/src/triggers/github/review-requested.ts @@ -1,4 +1,4 @@ -import { resolveGitHubTriggerEnabled } from '../../config/triggerConfig.js'; +import { resolveReviewTriggerConfig } from '../../config/triggerConfig.js'; import { isCascadeBot } from '../../github/personas.js'; import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; @@ -32,7 +32,8 @@ export class ReviewRequestedTrigger implements TriggerHandler { if (ctx.payload.action !== 'review_requested') return false; // Check trigger config — opt-in trigger, default disabled - if (!resolveGitHubTriggerEnabled(ctx.project.github?.triggers, 'reviewRequested')) { + const reviewConfig = resolveReviewTriggerConfig(ctx.project.github?.triggers); + if (!reviewConfig.onReviewRequested) { return false; } diff --git a/tests/unit/config/triggerConfig.test.ts b/tests/unit/config/triggerConfig.test.ts index 1da86683..0b912414 100644 --- a/tests/unit/config/triggerConfig.test.ts +++ b/tests/unit/config/triggerConfig.test.ts @@ -6,6 +6,7 @@ import { resolveGitHubTriggerEnabled, resolveJiraTriggerEnabled, resolveReadyToProcessEnabled, + resolveReviewTriggerConfig, resolveTrelloTriggerEnabled, } from '../../../src/config/triggerConfig.js'; @@ -71,6 +72,22 @@ describe('GitHubTriggerConfigSchema', () => { expect(result.reviewRequested).toBe(false); expect(result.prOpened).toBe(false); }); + + it('accepts reviewTrigger nested object', () => { + const result = GitHubTriggerConfigSchema.parse({ + reviewTrigger: { ownPrsOnly: true, externalPrs: false, onReviewRequested: true }, + }); + expect(result.reviewTrigger).toEqual({ + ownPrsOnly: true, + externalPrs: false, + onReviewRequested: true, + }); + }); + + it('reviewTrigger optional — absent by default', () => { + const result = GitHubTriggerConfigSchema.parse({}); + expect(result.reviewTrigger).toBeUndefined(); + }); }); describe('resolveTrelloTriggerEnabled', () => { @@ -212,3 +229,79 @@ describe('resolveReadyToProcessEnabled', () => { expect(resolveReadyToProcessEnabled(config, 'unknown-agent')).toBe(true); }); }); + +describe('resolveReviewTriggerConfig', () => { + it('maps legacy defaults when config is undefined (backward compatible)', () => { + // No config → legacy fallback: checkSuiteSuccess defaults to true → ownPrsOnly=true + // This preserves the existing behavior for projects with no trigger config + const result = resolveReviewTriggerConfig(undefined); + expect(result).toEqual({ ownPrsOnly: true, externalPrs: false, onReviewRequested: false }); + }); + + it('returns ownPrsOnly=true (legacy default) when config has no review-related keys', () => { + // checkSuiteSuccess is undefined → legacy default is true → ownPrsOnly=true + const result = resolveReviewTriggerConfig({ checkSuiteFailure: true }); + expect(result).toEqual({ ownPrsOnly: true, externalPrs: false, onReviewRequested: false }); + }); + + describe('new structured reviewTrigger config takes precedence', () => { + it('uses reviewTrigger object when present', () => { + const result = resolveReviewTriggerConfig({ + reviewTrigger: { ownPrsOnly: true, externalPrs: true, onReviewRequested: true }, + // Legacy booleans present but should be ignored + checkSuiteSuccess: false, + reviewRequested: false, + }); + expect(result).toEqual({ ownPrsOnly: true, externalPrs: true, onReviewRequested: true }); + }); + + it('uses reviewTrigger partial — missing fields default to false', () => { + const result = resolveReviewTriggerConfig({ + reviewTrigger: { ownPrsOnly: true, externalPrs: false, onReviewRequested: false }, + }); + expect(result.ownPrsOnly).toBe(true); + expect(result.externalPrs).toBe(false); + expect(result.onReviewRequested).toBe(false); + }); + + it('externalPrs can be independently enabled', () => { + const result = resolveReviewTriggerConfig({ + reviewTrigger: { ownPrsOnly: false, externalPrs: true, onReviewRequested: false }, + }); + expect(result.ownPrsOnly).toBe(false); + expect(result.externalPrs).toBe(true); + expect(result.onReviewRequested).toBe(false); + }); + }); + + describe('legacy boolean fallback', () => { + it('maps checkSuiteSuccess=true to ownPrsOnly=true (legacy default)', () => { + const result = resolveReviewTriggerConfig({ checkSuiteSuccess: true }); + expect(result.ownPrsOnly).toBe(true); + expect(result.externalPrs).toBe(false); + }); + + it('maps checkSuiteSuccess=false to ownPrsOnly=false', () => { + const result = resolveReviewTriggerConfig({ checkSuiteSuccess: false }); + expect(result.ownPrsOnly).toBe(false); + }); + + it('maps reviewRequested=true to onReviewRequested=true', () => { + const result = resolveReviewTriggerConfig({ reviewRequested: true }); + expect(result.onReviewRequested).toBe(true); + }); + + it('maps reviewRequested=false to onReviewRequested=false', () => { + const result = resolveReviewTriggerConfig({ reviewRequested: false }); + expect(result.onReviewRequested).toBe(false); + }); + + it('externalPrs is always false in legacy mode (no legacy equivalent)', () => { + const result = resolveReviewTriggerConfig({ + checkSuiteSuccess: true, + reviewRequested: true, + }); + expect(result.externalPrs).toBe(false); + }); + }); +}); diff --git a/tests/unit/triggers/check-suite-success.test.ts b/tests/unit/triggers/check-suite-success.test.ts index 02ec10d5..d8c977db 100644 --- a/tests/unit/triggers/check-suite-success.test.ts +++ b/tests/unit/triggers/check-suite-success.test.ts @@ -721,4 +721,214 @@ describe('CheckSuiteSuccessTrigger', () => { expect(result?.workItemId).toBe('db-work-item'); }); }); + + describe('reviewTrigger mode-aware behavior', () => { + /** Project with only externalPrs enabled */ + const mockProjectExternalOnly = { + ...mockProject, + github: { + triggers: { + reviewTrigger: { ownPrsOnly: false, externalPrs: true, onReviewRequested: false }, + }, + }, + }; + + /** Project with both ownPrsOnly and externalPrs enabled */ + const mockProjectBothModes = { + ...mockProject, + github: { + triggers: { + reviewTrigger: { ownPrsOnly: true, externalPrs: true, onReviewRequested: false }, + }, + }, + }; + + /** Project with all modes disabled */ + const mockProjectNoModes = { + ...mockProject, + github: { + triggers: { + reviewTrigger: { ownPrsOnly: false, externalPrs: false, onReviewRequested: false }, + }, + }, + }; + + it('does not match when all modes are disabled', () => { + const ctx: TriggerContext = { + project: mockProjectNoModes, + source: 'github', + payload: makeCheckSuitePayload(), + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('matches when externalPrs is enabled', () => { + const ctx: TriggerContext = { + project: mockProjectExternalOnly, + source: 'github', + payload: makeCheckSuitePayload(), + }; + expect(trigger.matches(ctx)).toBe(true); + }); + + it('matches when both modes are enabled', () => { + const ctx: TriggerContext = { + project: mockProjectBothModes, + source: 'github', + payload: makeCheckSuitePayload(), + }; + expect(trigger.matches(ctx)).toBe(true); + }); + + it('triggers for external PR author when externalPrs=true', async () => { + vi.mocked(githubClient.getPR).mockResolvedValue({ + number: 42, + title: 'External PR', + body: 'https://trello.com/c/abc123', + state: 'open', + headRef: 'feature/external', + headSha: 'sha123', + baseRef: 'main', + merged: false, + htmlUrl: 'https://github.com/owner/repo/pull/42', + user: { login: 'external-contributor' }, + }); + vi.mocked(githubClient.getPRReviews).mockResolvedValue([]); + vi.mocked(githubClient.getCheckSuiteStatus).mockResolvedValue({ + allPassing: true, + totalCount: 1, + checkRuns: [{ name: 'test', status: 'completed', conclusion: 'success' }], + }); + + const ctx: TriggerContext = { + project: mockProjectExternalOnly, + source: 'github', + payload: makeCheckSuitePayload(), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('review'); + }); + + it('skips implementer PR when only externalPrs=true', async () => { + vi.mocked(githubClient.getPR).mockResolvedValue({ + number: 42, + title: 'Implementer PR', + body: 'https://trello.com/c/abc123', + state: 'open', + headRef: 'feature/impl', + headSha: 'sha123', + baseRef: 'main', + merged: false, + htmlUrl: 'https://github.com/owner/repo/pull/42', + user: { login: 'cascade-impl' }, + }); + + const ctx: TriggerContext = { + project: mockProjectExternalOnly, + source: 'github', + payload: makeCheckSuitePayload(), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + + expect(result).toBeNull(); + }); + + it('triggers for both authors when ownPrsOnly=true and externalPrs=true', async () => { + const setupMocks = (authorLogin: string) => { + vi.mocked(githubClient.getPR).mockResolvedValue({ + number: 42, + title: 'Test PR', + body: null, + state: 'open', + headRef: 'feature/test', + headSha: 'sha123', + baseRef: 'main', + merged: false, + htmlUrl: 'https://github.com/owner/repo/pull/42', + user: { login: authorLogin }, + }); + vi.mocked(githubClient.getPRReviews).mockResolvedValue([]); + vi.mocked(githubClient.getCheckSuiteStatus).mockResolvedValue({ + allPassing: true, + totalCount: 1, + checkRuns: [{ name: 'test', status: 'completed', conclusion: 'success' }], + }); + }; + + // Implementer PR + setupMocks('cascade-impl'); + const implCtx: TriggerContext = { + project: mockProjectBothModes, + source: 'github', + payload: makeCheckSuitePayload(), + personaIdentities: mockPersonaIdentities, + }; + const implResult = await trigger.handle(implCtx); + expect(implResult).not.toBeNull(); + + // External PR + vi.clearAllMocks(); + vi.mocked(lookupWorkItemForPR).mockResolvedValue(null); + setupMocks('external-contributor'); + const extCtx: TriggerContext = { + project: mockProjectBothModes, + source: 'github', + payload: makeCheckSuitePayload(), + personaIdentities: mockPersonaIdentities, + }; + const extResult = await trigger.handle(extCtx); + expect(extResult).not.toBeNull(); + }); + + it('backward compat: legacy checkSuiteSuccess=true still triggers for implementer PRs', async () => { + vi.mocked(githubClient.getPR).mockResolvedValue({ + number: 42, + title: 'Test PR', + body: null, + state: 'open', + headRef: 'feature/test', + headSha: 'sha123', + baseRef: 'main', + merged: false, + htmlUrl: 'https://github.com/owner/repo/pull/42', + user: { login: 'cascade-impl' }, + }); + vi.mocked(githubClient.getPRReviews).mockResolvedValue([]); + vi.mocked(githubClient.getCheckSuiteStatus).mockResolvedValue({ + allPassing: true, + totalCount: 1, + checkRuns: [{ name: 'test', status: 'completed', conclusion: 'success' }], + }); + + // mockProject has no github triggers — resolves to legacy defaults (ownPrsOnly=true) + const ctx: TriggerContext = { + project: mockProject, + source: 'github', + payload: makeCheckSuitePayload(), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('review'); + }); + + it('backward compat: legacy checkSuiteSuccess=false skips even implementer PRs', () => { + const ctx: TriggerContext = { + project: { + ...mockProject, + github: { triggers: { checkSuiteSuccess: false } }, + }, + source: 'github', + payload: makeCheckSuitePayload(), + }; + expect(trigger.matches(ctx)).toBe(false); + }); + }); }); diff --git a/tests/unit/triggers/review-requested.test.ts b/tests/unit/triggers/review-requested.test.ts index b6156bba..2dcbb6e3 100644 --- a/tests/unit/triggers/review-requested.test.ts +++ b/tests/unit/triggers/review-requested.test.ts @@ -28,7 +28,7 @@ describe('ReviewRequestedTrigger', () => { // Review-requested is opt-in, default disabled }; - /** Project with reviewRequested trigger explicitly enabled */ + /** Project with reviewRequested trigger explicitly enabled (legacy style) */ const mockProjectWithReviewRequested = { ...mockProject, github: { @@ -36,6 +36,16 @@ describe('ReviewRequestedTrigger', () => { }, }; + /** Project with new structured reviewTrigger.onReviewRequested enabled */ + const mockProjectWithOnReviewRequested = { + ...mockProject, + github: { + triggers: { + reviewTrigger: { ownPrsOnly: false, externalPrs: false, onReviewRequested: true }, + }, + }, + }; + const mockPersonaIdentities = { implementer: 'cascade-impl', reviewer: 'cascade-reviewer', @@ -218,4 +228,64 @@ describe('ReviewRequestedTrigger', () => { expect(result?.agentType).toBe('review'); }); }); + + describe('new structured reviewTrigger config', () => { + it('matches when reviewTrigger.onReviewRequested=true', () => { + const ctx: TriggerContext = { + project: mockProjectWithOnReviewRequested, + source: 'github', + payload: makeReviewRequestedPayload(), + personaIdentities: mockPersonaIdentities, + }; + expect(trigger.matches(ctx)).toBe(true); + }); + + it('does not match when reviewTrigger.onReviewRequested=false', () => { + const ctx: TriggerContext = { + project: { + ...mockProject, + github: { + triggers: { + reviewTrigger: { ownPrsOnly: true, externalPrs: true, onReviewRequested: false }, + }, + }, + }, + source: 'github', + payload: makeReviewRequestedPayload(), + personaIdentities: mockPersonaIdentities, + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('new config takes precedence over legacy reviewRequested=false', () => { + // reviewTrigger.onReviewRequested=true wins even when legacy reviewRequested=false + const ctx: TriggerContext = { + project: { + ...mockProject, + github: { + triggers: { + reviewRequested: false, // legacy says disabled + reviewTrigger: { ownPrsOnly: false, externalPrs: false, onReviewRequested: true }, + }, + }, + }, + source: 'github', + payload: makeReviewRequestedPayload(), + personaIdentities: mockPersonaIdentities, + }; + expect(trigger.matches(ctx)).toBe(true); + }); + + it('triggers review agent using new config', async () => { + const ctx: TriggerContext = { + project: mockProjectWithOnReviewRequested, + source: 'github', + payload: makeReviewRequestedPayload('cascade-reviewer'), + personaIdentities: mockPersonaIdentities, + }; + const result = await trigger.handle(ctx); + expect(result).not.toBeNull(); + expect(result?.agentType).toBe('review'); + }); + }); }); diff --git a/web/src/lib/trigger-agent-mapping.ts b/web/src/lib/trigger-agent-mapping.ts index 0f5b9e49..6dd6a810 100644 --- a/web/src/lib/trigger-agent-mapping.ts +++ b/web/src/lib/trigger-agent-mapping.ts @@ -123,18 +123,28 @@ export const AGENT_TRIGGER_MAP: Record = { ], review: [ { - key: 'checkSuiteSuccess', - label: 'Check Suite Success', - description: 'Trigger review agent when all CI checks pass.', - defaultValue: true, + key: 'reviewTrigger.ownPrsOnly', + label: 'Own PRs Only', + description: + 'Trigger review agent when CI passes on PRs authored by the implementer persona.', + defaultValue: false, + scmProvider: 'github', + category: 'scm', + }, + { + key: 'reviewTrigger.externalPrs', + label: 'External PRs', + description: + 'Trigger review agent when CI passes on PRs authored by anyone (not just the implementer).', + defaultValue: false, scmProvider: 'github', category: 'scm', }, { - key: 'reviewRequested', - label: 'Review Requested (opt-in)', + key: 'reviewTrigger.onReviewRequested', + label: 'On Review Requested', description: - 'Trigger review agent when review is requested from a CASCADE persona. Default disabled.', + 'Trigger review agent when a CASCADE persona is explicitly requested as reviewer.', defaultValue: false, scmProvider: 'github', category: 'scm',