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
14 changes: 14 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,20 @@ node bin/cascade.js projects integration-set my-project \

If you enable the alerting agent, configure the optional `alerts` PM slot as well. For Trello this is `lists.alerts`; for Jira and Linear this is `statuses.alerts`. Sentry alerts materialize into that list/status before the alerting agent runs.

### Removing an integration

To detach an integration from a project — for example, when migrating from Trello to Linear — remove the stored integration config by category:

```bash
node bin/cascade.js projects integration-delete my-project --category pm --yes
```

This removes only the integration config row; project-scoped credentials (e.g. `TRELLO_TOKEN`, `LINEAR_API_KEY`) are intentionally retained so they can be reused with a replacement integration. To remove a credential as well, use `projects credentials-delete`:

```bash
node bin/cascade.js projects credentials-delete my-project --key TRELLO_TOKEN --yes
```

---

## 9. Set Up Webhooks
Expand Down
9 changes: 7 additions & 2 deletions src/agents/shared/promptContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,12 @@ export function buildPromptContext(
},
alertingResultsContainerId?: string,
): PromptContext {
const pmProvider = getPMProviderOrNull();
// An SCM-only project has NO_PM_PROVIDER (type 'none') in scope. Normalize it to
// `null` once so the whole context build treats it as "no PM provider" — otherwise
// the truthy sentinel reaches getWorkItemUrl() below (when a workItemId is carried
// via a stale pr_work_items row or a manual/retry path) and throws during boot.
const rawPmProvider = getPMProviderOrNull();
const pmProvider = rawPmProvider?.type === 'none' ? null : rawPmProvider;
const listIds = getListIds(project);
const terminology = getPromptTerminology(pmProvider?.type);

Expand All @@ -100,7 +105,7 @@ export function buildPromptContext(
...listIds,
backlogListId,
workItemCreateContainerId,
pmType: pmProvider?.type,
pmType: pmProvider && pmProvider.type !== 'none' ? pmProvider.type : undefined,
...terminology,
maxInFlightItems: project.maxInFlightItems ?? 1,
...(prContext && {
Expand Down
7 changes: 5 additions & 2 deletions src/backends/secretBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,11 @@ export async function augmentProjectSecrets(
projectSecrets.CASCADE_AGENT_TYPE = agentType;
injectAgentInputContext(projectSecrets, input);

// Inject PM type so cascade-tools uses the correct provider
projectSecrets.CASCADE_PM_TYPE = project.pm?.type ?? 'trello';
// Inject PM type so cascade-tools uses the correct provider. Omitted for
// SCM-only projects (no PM provider) so the worker doesn't assume Trello.
if (project.pm?.type) {
projectSecrets.CASCADE_PM_TYPE = project.pm.type;
}

return projectSecrets;
}
Expand Down
5 changes: 4 additions & 1 deletion src/backends/sidecarManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,10 @@ async function withProjectPMCredentials<T>(
project: ProjectConfig,
fn: () => Promise<T>,
): Promise<T> {
const integration = pmRegistry.getOrNull(project.pm?.type ?? 'trello');
// SCM-only projects (no PM provider) need no PM credential scope.
const pmType = project.pm?.type;
if (!pmType) return fn();
const integration = pmRegistry.getOrNull(pmType);
if (!integration) return fn();
return integration.withCredentials(project.id, fn);
}
Expand Down
45 changes: 45 additions & 0 deletions src/cli/dashboard/projects/integration-delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Args, Flags } from '@oclif/core';
import { DashboardCommand } from '../_shared/base.js';
import { confirm } from '../_shared/confirm.js';

export default class ProjectsIntegrationDelete extends DashboardCommand {
static override description = 'Delete an integration config for a project.';

static override args = {
id: Args.string({ description: 'Project ID', required: true }),
};

static override flags = {
...DashboardCommand.baseFlags,
category: Flags.string({
description: 'Integration category (pm, scm, or alerting)',
required: true,
options: ['pm', 'scm', 'alerting'],
}),
yes: Flags.boolean({ description: 'Skip confirmation', char: 'y', default: false }),
};

async run(): Promise<void> {
const { args, flags } = await this.parse(ProjectsIntegrationDelete);

await confirm(`Delete ${flags.category} integration from project ${args.id}?`, flags.yes);

try {
await this.withSpinner('Deleting integration...', () =>
this.client.projects.integrations.delete.mutate({
projectId: args.id,
category: flags.category as 'pm' | 'scm' | 'alerting',
}),
);

if (flags.json) {
this.outputJson({ ok: true });
return;
}

this.success(`Deleted ${flags.category} integration from project '${args.id}'`);
} catch (err) {
this.handleError(err);
}
}
}
8 changes: 6 additions & 2 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,15 @@ export const ProjectConfigSchema = z.object({
baseBranch: z.string().default('main'),
branchPrefix: z.string().default('feature/'),

// Optional: SCM-only projects have no PM provider. Absent `pm` (or a project
// with no trello/jira/linear integration) leaves this `undefined` — it is NOT
// defaulted to Trello (that silently broke SCM-only projects). See
// src/pm/no-pm-provider.ts.
pm: z
.object({
type: z.enum(['trello', 'jira', 'linear']).default('trello'),
type: z.enum(['trello', 'jira', 'linear']),
})
.default({ type: 'trello' }),
.optional(),

trello: trelloConfigSchema.optional(),

Expand Down
18 changes: 13 additions & 5 deletions src/db/repositories/configMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export interface ProjectConfigRaw {
repo?: string;
baseBranch: string;
branchPrefix: string;
pm: { type: string };
pm?: { type: string };
model?: string;
agentModels?: Record<string, string>;
maxIterations?: number;
Expand Down Expand Up @@ -232,7 +232,7 @@ function buildAgentEngineConfig(

function buildBaseProjectFields(
row: ProjectRow,
pmType: 'trello' | 'jira' | 'linear',
pmType: 'trello' | 'jira' | 'linear' | undefined,
): ProjectConfigRaw {
return {
id: row.id,
Expand All @@ -241,7 +241,7 @@ function buildBaseProjectFields(
repo: row.repo ?? undefined,
baseBranch: row.baseBranch ?? 'main',
branchPrefix: row.branchPrefix ?? 'feature/',
pm: { type: pmType },
pm: pmType ? { type: pmType } : undefined,
model: row.model ?? undefined,
maxIterations: row.maxIterations ?? undefined,
watchdogTimeoutMs: row.watchdogTimeoutMs ?? undefined,
Expand Down Expand Up @@ -292,8 +292,16 @@ export function mapProjectRow({
engineSettings: agentEngineSettingsMap,
} = buildAgentMaps(projectAgentConfigs);

// Derive PM type from integration config
const pmType = jiraConfig ? 'jira' : linearConfig ? 'linear' : 'trello';
// Derive PM type from integration config. No PM integration → `undefined`
// (an SCM-only project); do NOT default to Trello. Check trelloConfig
// explicitly (it is no longer the catch-all).
const pmType = trelloConfig
? 'trello'
: jiraConfig
? 'jira'
: linearConfig
? 'linear'
: undefined;

const project: ProjectConfigRaw = {
...buildBaseProjectFields(row, pmType),
Expand Down
2 changes: 1 addition & 1 deletion src/friction/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export interface FrictionProjectContext {
id: string;
name?: string;
repo?: string;
pmType?: ProjectConfig['pm']['type'];
pmType?: NonNullable<ProjectConfig['pm']>['type'];
}

export interface FrictionAgentContext {
Expand Down
11 changes: 7 additions & 4 deletions src/gadgets/pm/core/reportFriction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ function parseJsonRecord(value: string | undefined): Record<string, string> {
}

function projectFromEnv(): ProjectConfig {
const pmType = process.env.CASCADE_PM_TYPE as ProjectConfig['pm']['type'] | undefined;
const pmType = process.env.CASCADE_PM_TYPE as
| NonNullable<ProjectConfig['pm']>['type']
| undefined;
const base = {
id: process.env.CASCADE_PROJECT_ID ?? 'unknown-project',
orgId: process.env.CASCADE_ORG_ID ?? 'unknown-org',
Expand All @@ -80,10 +82,11 @@ function projectFromEnv(): ProjectConfig {
process.env.CASCADE_REPO_OWNER && process.env.CASCADE_REPO_NAME
? `${process.env.CASCADE_REPO_OWNER}/${process.env.CASCADE_REPO_NAME}`
: undefined,
pm: { type: pmType ?? 'trello' },
// SCM-only worker runs leave CASCADE_PM_TYPE unset → no synthesized PM.
pm: pmType ? { type: pmType } : undefined,
} as ProjectConfig;

if (base.pm.type === 'jira') {
if (base.pm?.type === 'jira') {
return {
...base,
jira: {
Expand All @@ -93,7 +96,7 @@ function projectFromEnv(): ProjectConfig {
},
} as ProjectConfig;
}
if (base.pm.type === 'linear') {
if (base.pm?.type === 'linear') {
return {
...base,
linear: {
Expand Down
56 changes: 56 additions & 0 deletions src/pm/no-pm-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { PMProvider } from './types.js';

const MESSAGE =
'This project has no PM provider configured (SCM-only project). PM operations are unavailable.';

function rejectNoPM(): Promise<never> {
return Promise.reject(new Error(MESSAGE));
}

function throwNoPM(): never {
throw new Error(MESSAGE);
}

/**
* Sentinel `PMProvider` for SCM-only projects (a project with an SCM integration
* but no PM provider).
*
* `pmRegistry.createProvider` returns this when `project.pm` is undefined, so
* `withPMProvider(provider, fn)` — which requires a non-null `PMProvider` — stays
* type-safe and SCM dispatch never resolves a phantom Trello provider (the bug this
* fixes). Every PM operation fails loudly: a PM-less project should never reach one.
* SCM trigger handlers that opportunistically enrich via `getPMProviderOrNull()`
* already wrap such calls in try/catch and degrade gracefully.
*/
export const NO_PM_PROVIDER: PMProvider = {
type: 'none',

getWorkItem: () => rejectNoPM(),
getWorkItemComments: () => rejectNoPM(),
updateWorkItem: () => rejectNoPM(),
addComment: () => rejectNoPM(),
updateComment: () => rejectNoPM(),
createWorkItem: () => rejectNoPM(),
listWorkItems: () => rejectNoPM(),

moveWorkItem: () => rejectNoPM(),
addLabel: () => rejectNoPM(),
removeLabel: () => rejectNoPM(),

getChecklists: () => rejectNoPM(),
createChecklist: () => rejectNoPM(),
addChecklistItem: () => rejectNoPM(),
updateChecklistItem: () => rejectNoPM(),
deleteChecklistItem: () => rejectNoPM(),

getAttachments: () => rejectNoPM(),
addAttachment: () => rejectNoPM(),
addAttachmentFile: () => rejectNoPM(),
getCustomFieldNumber: () => rejectNoPM(),
updateCustomFieldNumber: () => rejectNoPM(),

linkPR: () => rejectNoPM(),

getWorkItemUrl: () => throwNoPM(),
getAuthenticatedUser: () => rejectNoPM(),
};
15 changes: 11 additions & 4 deletions src/pm/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type { ProjectConfig } from '../types/index.js';
import { logger } from '../utils/logging.js';
import type { PMIntegration } from './integration.js';
import type { ProjectPMConfig } from './lifecycle.js';
import { NO_PM_PROVIDER } from './no-pm-provider.js';
import type { PMProvider } from './types.js';

class PMIntegrationRegistry {
Expand Down Expand Up @@ -56,15 +57,21 @@ class PMIntegrationRegistry {
return listPMProviders().map((m: PMProviderManifest) => m.pmIntegration);
}

/** Convenience: resolve the project's PM provider and create its PMProvider. */
/**
* Convenience: resolve the project's PM provider and create its PMProvider.
* SCM-only projects (no `pm`) get the no-op {@link NO_PM_PROVIDER} sentinel —
* never a phantom Trello provider.
*/
createProvider(project: ProjectConfig): PMProvider {
const type = project.pm?.type ?? 'trello';
const type = project.pm?.type;
if (!type) return NO_PM_PROVIDER;
return this.get(type).createProvider(project);
}

/** Convenience: resolve lifecycle config from project. */
/** Convenience: resolve lifecycle config from project. SCM-only → empty config. */
resolveLifecycleConfig(project: ProjectConfig): ProjectPMConfig {
const type = project.pm?.type ?? 'trello';
const type = project.pm?.type;
if (!type) return { labels: {}, statuses: {} };
return this.get(type).resolveLifecycleConfig(project);
}
}
Expand Down
5 changes: 4 additions & 1 deletion src/pm/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,10 @@ export interface ListWorkItemsFilter {
}

export interface PMProvider {
readonly type: PMType;
// `'none'` is the SCM-only sentinel (see src/pm/no-pm-provider.ts); real
// providers narrow to their PMType. Consumers switching on `type` fall to
// their default branch for `'none'`.
readonly type: PMType | 'none';

// Core CRUD
getWorkItem(id: string): Promise<WorkItem>;
Expand Down
4 changes: 2 additions & 2 deletions src/router/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { CascadeConfig, ProjectConfig } from '../types/index.js';
export interface RouterProjectConfig {
id: string;
repo?: string; // owner/repo format (optional for projects without SCM integration)
pmType: 'trello' | 'jira' | 'linear';
pmType?: 'trello' | 'jira' | 'linear'; // undefined for SCM-only projects (no PM provider)
trello?: {
boardId: string;
lists: Record<string, string>;
Expand Down Expand Up @@ -98,7 +98,7 @@ export async function loadProjectConfig(): Promise<{
return {
id: p.id,
repo: p.repo,
pmType: p.pm?.type ?? 'trello',
pmType: p.pm?.type,
...(trelloConfig && {
trello: {
boardId: trelloConfig.boardId,
Expand Down
4 changes: 4 additions & 0 deletions src/triggers/shared/backlog-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ function isProviderMisconfigured(project: ProjectConfig, provider: PMProvider):
const linear = getLinearConfig(project);
return !linear?.teamId || !linear.statuses?.backlog;
}
// SCM-only projects have no PM provider (no backlog). This branch is never
// reached on the PM status-changed capacity path, but keeps the switch exhaustive.
case 'none':
return true;
default:
return assertNeverPMType(provider.type);
}
Expand Down
2 changes: 1 addition & 1 deletion src/triggers/shared/splitting-auto-chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export async function buildSplittingAutoChainDispatch(
// pmConfig.labels.auto may be a human-readable name string rather than a
// provider-native ID.
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (project.pm.type === 'linear' && !UUID_REGEX.test(autoLabelId)) {
if (project.pm?.type === 'linear' && !UUID_REGEX.test(autoLabelId)) {
logger.warn(
'propagateAutoLabelAfterSplitting: labels.auto is not a UUID; resolving ID from parent labels',
{ autoLabelId },
Expand Down
17 changes: 17 additions & 0 deletions tests/helpers/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,23 @@ export function createMockProject(overrides?: Partial<ProjectConfig>): ProjectCo
} as ProjectConfig;
}

/**
* Creates a mock SCM-only project config — a GitHub repo with NO PM provider
* (no `pm`, no trello/jira/linear block). Mirrors a project configured with
* only an `scm`/`github` integration; `pm` is `undefined`.
*/
export function createMockGitHubOnlyProject(overrides?: Partial<ProjectConfig>): ProjectConfig {
return {
id: 'test',
orgId: 'org-1',
name: 'Test',
repo: 'owner/repo',
baseBranch: 'main',
branchPrefix: 'feature/',
...overrides,
} as ProjectConfig;
}

/**
* Creates a mock JIRA project config.
*/
Expand Down
Loading
Loading