Skip to content
Closed
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
22 changes: 22 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,28 @@ cascade projects integration-credential-set <project-id> --category scm --role r
- `respond-to-pr-comment` skips @mentions from **any** known persona
- `check-suite-success` checks reviews from the **reviewer** persona specifically

### Review Agent Scope (`reviewScope`)

The `reviewScope` field in the SCM integration `triggers` JSONB controls which PRs trigger the `review` agent. It is an array of modes that compose:

| Mode | Description |
|------|-------------|
| `own` | CI passes on a PR authored by the implementer persona |
| `all` | CI passes on any PR (non-implementer PRs included); also enables PR Opened for respond-to-review |
| `reviewRequested` | Review explicitly requested from a CASCADE persona |

**Default: `["reviewRequested"]`** — review fires only when explicitly requested. Configure via the Dashboard (Agent Configs tab) or directly in the `triggers` JSONB column.

Examples:
```json
{ "reviewScope": ["reviewRequested"] } // default — review on explicit request only
{ "reviewScope": ["own"] } // auto-review own PRs after CI passes
{ "reviewScope": ["own", "reviewRequested"] } // both: own PRs + explicit requests
{ "reviewScope": ["all"] } // auto-review all PRs after CI passes
```

**Migration note**: The old `checkSuiteSuccess` boolean defaulted to `true`, meaning CI-success auto-review on implementer PRs was enabled by default. The new `reviewScope` defaults to `['reviewRequested']`, which does **not** include `'own'`. Existing projects that relied on the default auto-review-after-CI behavior will need to explicitly set `reviewScope: ['own', 'reviewRequested']` to restore that behavior. Projects that had `checkSuiteSuccess: true` stored in their triggers JSONB should be updated — the old key is ignored by the new code.

### Integration Credential Resolution

Integration credentials are resolved by `(projectId, category, role)`:
Expand Down
6 changes: 4 additions & 2 deletions src/api/routers/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@ export const projectsRouter = router({
category: z.enum(['pm', 'scm']),
provider: z.string().min(1),
config: z.record(z.unknown()),
triggers: z.record(z.boolean()).optional(),
triggers: z
.record(z.union([z.boolean(), z.record(z.boolean()), z.array(z.string())]))
.optional(),
}),
)
.mutation(async ({ ctx, input }) => {
Expand All @@ -143,7 +145,7 @@ export const projectsRouter = router({
z.object({
projectId: z.string(),
category: z.enum(['pm', 'scm']),
triggers: z.record(z.union([z.boolean(), z.record(z.boolean())])),
triggers: z.record(z.union([z.boolean(), z.record(z.boolean()), z.array(z.string())])),
}),
)
.mutation(async ({ ctx, input }) => {
Expand Down
66 changes: 48 additions & 18 deletions src/config/triggerConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,27 +43,48 @@ export const JiraTriggerConfigSchema = z.object({
commentMention: z.boolean().default(true),
});

/**
* Controls which PRs trigger the review agent.
* - `own`: CI passes on a PR authored by the implementer persona
* - `all`: CI passes on any PR (non-implementer PRs included)
* - `reviewRequested`: review is explicitly requested from a CASCADE persona
* Modes compose — e.g., `['own', 'reviewRequested']` enables both.
* Default: `['reviewRequested']` — review only fires when explicitly requested.
*/
export const ReviewScopeSchema = z
.array(z.enum(['own', 'all', 'reviewRequested']))
.default(['reviewRequested']);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Behavioral change worth confirming: The old checkSuiteSuccess defaulted to true (auto-review on CI pass was enabled by default). This new default of ["reviewRequested"] means existing projects without explicit reviewScope config will silently lose CI-triggered auto-reviews. Is this intentional? If so, worth calling out explicitly in the breaking change note. If not, consider defaulting to ["own", "reviewRequested"] to preserve backward compatibility.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch. This is intentional — we're moving from opt-out to opt-in for CI-triggered reviews. Added a migration note to CLAUDE.md explicitly documenting this behavioral change and instructing existing projects to set reviewScope: ['own', 'reviewRequested'] to restore the old default behavior.


export type ReviewScope = z.infer<typeof ReviewScopeSchema>;

/**
* Trigger configuration for GitHub integrations.
* Existing triggers default to `true`; new triggers (`reviewRequested`, `prOpened`) default to `false`.
*/
export const GitHubTriggerConfigSchema = z.object({
checkSuiteSuccess: z.boolean().default(true),
checkSuiteFailure: z.boolean().default(true),
prReviewSubmitted: z.boolean().default(true),
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). */
reviewRequested: z.boolean().default(false),
/** PR opened trigger. Default false (disabled until reviewed). */
prOpened: z.boolean().default(false),
/** Controls which PRs trigger the review agent. See ReviewScopeSchema for options. */
reviewScope: ReviewScopeSchema,
});

export type TrelloTriggerConfig = z.infer<typeof TrelloTriggerConfigSchema>;
export type JiraTriggerConfig = z.infer<typeof JiraTriggerConfigSchema>;
export type GitHubTriggerConfig = z.infer<typeof GitHubTriggerConfigSchema>;

// ============================================================================
// Review Scope Helper
// ============================================================================

/**
* Returns whether the given reviewScope array includes the specified mode.
*/
export function isReviewScopeEnabled(scope: ReviewScope, mode: ReviewScope[number]): boolean {
return scope.includes(mode);
}

// ============================================================================
// Helpers
// ============================================================================
Expand Down Expand Up @@ -132,24 +153,33 @@ export function resolveJiraTriggerEnabled(
return value === undefined ? true : (value as boolean);
}

/** Boolean-only keys from GitHubTriggerConfig (excludes array fields like reviewScope). */
type GitHubBooleanTriggerKey = {
[K in keyof GitHubTriggerConfig]: GitHubTriggerConfig[K] extends boolean ? K : never;
}[keyof GitHubTriggerConfig];

/**
* Resolve whether a GitHub trigger is enabled based on project trigger config.
* For new opt-in triggers (reviewRequested, prOpened), returns `false` when no config is present.
* Returns `true` (enabled) when no config is present (backward compatible).
* Only accepts boolean trigger keys — use `resolveReviewScope` for array-typed fields.
*/
export function resolveGitHubTriggerEnabled(
config: Partial<GitHubTriggerConfig> | undefined,
key: keyof GitHubTriggerConfig,
key: GitHubBooleanTriggerKey,
): boolean {
if (!config) {
// New triggers that are opt-in default to false even without config
if (key === 'reviewRequested' || key === 'prOpened') return false;
return true;
}
if (!config) return true;
const value = config[key];
if (value === undefined) {
// New triggers that are opt-in default to false
if (key === 'reviewRequested' || key === 'prOpened') return false;
return true;
}
if (value === undefined) return true;
return value;
}

/**
* Resolve the reviewScope from project GitHub trigger config.
* Returns the default scope when no config is present.
*/
export function resolveReviewScope(config: Partial<GitHubTriggerConfig> | undefined): ReviewScope {
if (!config || config.reviewScope === undefined) {
return ReviewScopeSchema.parse(undefined);
}
return config.reviewScope;
}
5 changes: 3 additions & 2 deletions src/db/repositories/settingsRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,14 +166,15 @@ export async function upsertProjectIntegration(
category: string,
provider: string,
config: Record<string, unknown>,
triggers?: Record<string, boolean>,
triggers?: Record<string, boolean | Record<string, boolean> | string[]>,
) {
const db = getDb();
// Preserve existing triggers if not provided (prevents data loss from Integration tab saves)
let triggersToSave = triggers;
if (triggersToSave === undefined) {
const existing = await getIntegrationByProjectAndCategory(projectId, category);
triggersToSave = (existing?.triggers as Record<string, boolean>) ?? {};
triggersToSave =
(existing?.triggers as Record<string, boolean | Record<string, boolean> | string[]>) ?? {};
}
const [row] = await db
.insert(projectIntegrations)
Expand Down
4 changes: 2 additions & 2 deletions src/triggers/builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export function registerBuiltInTriggers(registry: TriggerRegistry): void {
registry.register(new JiraReadyToProcessLabelTrigger());

// GitHub: PR opened trigger (initial review on new PRs)
// Opt-in: disabled by default via trigger config (github.triggers.prOpened = false)
// Fires when reviewScope includes 'all' (disabled by default — default reviewScope is ['reviewRequested'])
registry.register(new PROpenedTrigger());

// GitHub: PR comment @mention trigger (runs respond-to-pr-comment when reviewer is @mentioned)
Expand All @@ -66,7 +66,7 @@ export function registerBuiltInTriggers(registry: TriggerRegistry): void {
registry.register(new PRReviewSubmittedTrigger());

// GitHub: Review requested trigger (runs review agent when review is requested from CASCADE persona)
// Opt-in: disabled by default via trigger config (github.triggers.reviewRequested = false)
// Fires when reviewScope includes 'reviewRequested' (enabled by default — default reviewScope is ['reviewRequested'])
// Registered before CheckSuiteSuccessTrigger so both can independently trigger review
registry.register(new ReviewRequestedTrigger());

Expand Down
30 changes: 17 additions & 13 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 { isReviewScopeEnabled, resolveReviewScope } 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 — only fire when reviewScope includes 'own' or 'all'
const reviewScope = resolveReviewScope(ctx.project.github?.triggers);
if (!isReviewScopeEnabled(reviewScope, 'own') && !isReviewScopeEnabled(reviewScope, 'all')) {
return false;
}

Expand Down Expand Up @@ -99,15 +100,18 @@ export class CheckSuiteSuccessTrigger implements TriggerHandler {
// Fetch PR details
const prDetails = await githubClient.getPR(owner, repo, prNumber);

// Gate on PR author being the implementer persona
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', {
prNumber,
prAuthor: prDetails.user.login,
});
return null;
// Gate on PR author being the implementer persona — unless scope includes 'all'
const reviewScope = resolveReviewScope(ctx.project.github?.triggers);
if (!isReviewScopeEnabled(reviewScope, 'all')) {
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', {
prNumber,
prAuthor: prDetails.user.login,
});
return null;
}
}

// Only trigger for PRs targeting the project's base branch
Expand All @@ -132,7 +136,7 @@ export class CheckSuiteSuccessTrigger implements TriggerHandler {
const reviews = await githubClient.getPRReviews(owner, repo, prNumber);

// Use persona identities to identify reviewer bot's reviews
const reviewerUsername = ctx.personaIdentities.reviewer;
const reviewerUsername = ctx.personaIdentities?.reviewer;

// Only consider actual reviews (approved/changes_requested), not COMMENTED
// which are reply acknowledgments posted by respond-to-review agent
Expand Down
7 changes: 4 additions & 3 deletions src/triggers/github/pr-opened.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { resolveGitHubTriggerEnabled } from '../../config/triggerConfig.js';
import { isReviewScopeEnabled, resolveReviewScope } from '../../config/triggerConfig.js';
import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js';
import { logger } from '../../utils/logging.js';
import { isGitHubPullRequestPayload } from './types.js';
Expand All @@ -16,8 +16,9 @@ export class PROpenedTrigger implements TriggerHandler {
if (ctx.source !== 'github') return false;
if (!isGitHubPullRequestPayload(ctx.payload)) return false;

// Check trigger config — opt-in trigger, default disabled
if (!resolveGitHubTriggerEnabled(ctx.project.github?.triggers, 'prOpened')) {
// Check trigger config — only fire when reviewScope includes 'all'
const reviewScope = resolveReviewScope(ctx.project.github?.triggers);
if (!isReviewScopeEnabled(reviewScope, 'all')) {
return false;
}

Expand Down
12 changes: 7 additions & 5 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 { isReviewScopeEnabled, resolveReviewScope } 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 All @@ -13,8 +13,9 @@ import { resolveWorkItemId } from './utils.js';
* 2. Checks if the requested reviewer is a CASCADE persona (implementer OR reviewer)
* 3. Fires the `review` agent with PR number and work item ID from PR body
*
* Default: **disabled** (opt-in via trigger config). Enable by setting
* `github.triggers.reviewRequested = true` in integration config.
* Default: **enabled** — the default `reviewScope` of `['reviewRequested']` includes
* this trigger. Disable by setting `reviewScope` to a value that excludes
* `'reviewRequested'` (e.g., `['own']`).
*
* Registration: should be registered BEFORE CheckSuiteSuccessTrigger so that
* both triggers can independently fire review. The HEAD-SHA dedup in
Expand All @@ -31,8 +32,9 @@ export class ReviewRequestedTrigger implements TriggerHandler {
// Only trigger on review_requested events
if (ctx.payload.action !== 'review_requested') return false;

// Check trigger config — opt-in trigger, default disabled
if (!resolveGitHubTriggerEnabled(ctx.project.github?.triggers, 'reviewRequested')) {
// Check trigger config — only fire when reviewScope includes 'reviewRequested'
const reviewScope = resolveReviewScope(ctx.project.github?.triggers);
if (!isReviewScopeEnabled(reviewScope, 'reviewRequested')) {
return false;
}

Expand Down
Loading