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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project-id> --own-prs-only

# Enable review for external contributor PRs
cascade projects review-trigger-set <project-id> --external-prs

# Enable both CI-triggered modes
cascade projects review-trigger-set <project-id> --own-prs-only --external-prs

# Enable review when explicitly requested
cascade projects review-trigger-set <project-id> --on-review-requested

# Disable a mode
cascade projects review-trigger-set <project-id> --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 <project-id> \
--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:
Expand Down
86 changes: 86 additions & 0 deletions src/cli/dashboard/projects/review-trigger-set.ts
Original file line number Diff line number Diff line change
@@ -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 <project-id> [--own-prs-only] [--external-prs] [--on-review-requested]
*
* At least one flag must be provided. Pass `--no-<flag>` 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<void> {
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-<flag> to disable).',
);
}

// Build the nested reviewTrigger object with only the provided flags
const reviewTrigger: Record<string, boolean> = {};
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);
}
}
}
61 changes: 60 additions & 1 deletion src/config/triggerConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ReviewTriggerConfigSchema>;

/**
* Trigger configuration for GitHub integrations.
* Existing triggers default to `true`; new triggers (`reviewRequested`, `prOpened`) default to `false`.
Expand All @@ -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<typeof TrelloTriggerConfigSchema>;
export type JiraTriggerConfig = z.infer<typeof JiraTriggerConfigSchema>;
export type GitHubTriggerConfig = z.infer<typeof GitHubTriggerConfigSchema>;

// ============================================================================
// 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<GitHubTriggerConfig> | 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
// ============================================================================
Expand Down Expand Up @@ -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;
}
26 changes: 19 additions & 7 deletions src/triggers/github/check-suite-success.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions src/triggers/github/review-requested.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down
Loading