From c072cf1aaa2bccd1db060d309e9c6be510e0dd86 Mon Sep 17 00:00:00 2001 From: aaight Date: Mon, 22 Jun 2026 12:39:40 +0200 Subject: [PATCH 1/3] feat(cli): add projects integration-delete command (#1416) Co-authored-by: Cascade Bot --- docs/getting-started.md | 14 +++ .../dashboard/projects/integration-delete.ts | 45 +++++++ .../cli/dashboard/projects/projects.test.ts | 114 ++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 src/cli/dashboard/projects/integration-delete.ts diff --git a/docs/getting-started.md b/docs/getting-started.md index a744b6d44..df496e662 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -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 diff --git a/src/cli/dashboard/projects/integration-delete.ts b/src/cli/dashboard/projects/integration-delete.ts new file mode 100644 index 000000000..442bafb58 --- /dev/null +++ b/src/cli/dashboard/projects/integration-delete.ts @@ -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 { + 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); + } + } +} diff --git a/tests/unit/cli/dashboard/projects/projects.test.ts b/tests/unit/cli/dashboard/projects/projects.test.ts index 4dc4c3aa6..813b8734d 100644 --- a/tests/unit/cli/dashboard/projects/projects.test.ts +++ b/tests/unit/cli/dashboard/projects/projects.test.ts @@ -24,6 +24,7 @@ vi.mock('chalk', () => ({ import ProjectsCreate from '../../../../../src/cli/dashboard/projects/create.js'; import ProjectsDelete from '../../../../../src/cli/dashboard/projects/delete.js'; +import ProjectsIntegrationDelete from '../../../../../src/cli/dashboard/projects/integration-delete.js'; import ProjectsIntegrationSet from '../../../../../src/cli/dashboard/projects/integration-set.js'; import ProjectsIntegrations from '../../../../../src/cli/dashboard/projects/integrations.js'; import ProjectsList from '../../../../../src/cli/dashboard/projects/list.js'; @@ -61,6 +62,7 @@ function makeClient(overrides: Record = {}) { integrations: { list: { query: vi.fn().mockResolvedValue([]) }, upsert: { mutate: vi.fn().mockResolvedValue(undefined) }, + delete: { mutate: vi.fn().mockResolvedValue(undefined) }, }, }, agentConfigs: { @@ -705,6 +707,118 @@ describe('ProjectsIntegrationSet (integration-set)', () => { }); }); +// --------------------------------------------------------------------------- +// projects integration-delete +// --------------------------------------------------------------------------- +describe('ProjectsIntegrationDelete (integration-delete)', () => { + beforeEach(() => { + mockLoadConfig.mockReturnValue(baseConfig); + }); + + it('passes project ID and category to integrations delete mutate with --yes', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new ProjectsIntegrationDelete( + ['my-project', '--category', 'pm', '--yes'], + oclifConfig as never, + ); + await cmd.run(); + + expect(client.projects.integrations.delete.mutate).toHaveBeenCalledWith({ + projectId: 'my-project', + category: 'pm', + }); + }); + + it('deletes the scm category integration', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new ProjectsIntegrationDelete( + ['my-project', '--category', 'scm', '--yes'], + oclifConfig as never, + ); + await cmd.run(); + + expect(client.projects.integrations.delete.mutate).toHaveBeenCalledWith({ + projectId: 'my-project', + category: 'scm', + }); + }); + + it('deletes the alerting category integration', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new ProjectsIntegrationDelete( + ['my-project', '--category', 'alerting', '--yes'], + oclifConfig as never, + ); + await cmd.run(); + + expect(client.projects.integrations.delete.mutate).toHaveBeenCalledWith({ + projectId: 'my-project', + category: 'alerting', + }); + }); + + it('auto-accepts without --yes in non-TTY environments', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new ProjectsIntegrationDelete( + ['my-project', '--category', 'pm'], + oclifConfig as never, + ); + await expect(cmd.run()).resolves.toBeUndefined(); + expect(client.projects.integrations.delete.mutate).toHaveBeenCalledWith({ + projectId: 'my-project', + category: 'pm', + }); + }); + + it('requires --category flag', async () => { + mockCreateDashboardClient.mockReturnValue(makeClient()); + + const cmd = new ProjectsIntegrationDelete(['my-project', '--yes'], oclifConfig as never); + await expect(cmd.run()).rejects.toThrow(); + }); + + it('rejects an invalid --category value', async () => { + mockCreateDashboardClient.mockReturnValue(makeClient()); + + const cmd = new ProjectsIntegrationDelete( + ['my-project', '--category', 'invalid', '--yes'], + oclifConfig as never, + ); + await expect(cmd.run()).rejects.toThrow(); + }); + + it('requires project ID argument', async () => { + mockCreateDashboardClient.mockReturnValue(makeClient()); + + const cmd = new ProjectsIntegrationDelete(['--category', 'pm', '--yes'], oclifConfig as never); + await expect(cmd.run()).rejects.toThrow(); + }); + + it('outputs json when --json flag is set', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new ProjectsIntegrationDelete( + ['my-project', '--category', 'pm', '--yes', '--json'], + oclifConfig as never, + ); + await cmd.run(); + + expect(client.projects.integrations.delete.mutate).toHaveBeenCalledWith({ + projectId: 'my-project', + category: 'pm', + }); + }); +}); + // --------------------------------------------------------------------------- // projects trigger-discover // --------------------------------------------------------------------------- From 0144e7d749a10fe6392a6026a515e8682bb18476 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Mon, 22 Jun 2026 13:03:02 +0200 Subject: [PATCH 2/3] fix(pm): support SCM-only projects (no PM provider) instead of defaulting to Trello MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project configured with only an SCM (GitHub) integration and no PM provider was silently treated as Trello: the DB→config mapper defaulted pm.type to 'trello' when no PM integration row existed (and the schema + registry re-applied that default). Every SCM trigger (review, respond-to-review, check-suite-*, respond-to-pr-comment) then failed dispatch trying to load a Trello credential that does not exist: Integration credential 'pm/trello/api_key' not found for project '' Make the PM provider optional end-to-end: - schema: `pm` is optional with no Trello default (src/config/schema.ts). - config mapper: derive pm.type only from a present trello/jira/linear integration; leave `pm` undefined for SCM-only projects (src/db/repositories/configMapper.ts). - registry: createProvider returns a new NO_PM_PROVIDER sentinel (and resolveLifecycleConfig an empty config) when pm is undefined — never a phantom Trello provider (src/pm/registry.ts, src/pm/no-pm-provider.ts). withPMProvider requires a non-null PMProvider, so a no-op sentinel keeps every call site type-safe; its PM operations throw loudly if ever reached. - propagate the optional type through router config, prompt context, friction types, and the worker env (CASCADE_PM_TYPE is omitted for SCM-only projects). withPMCredentials already short-circuits on an undefined pmType, and the capacity gate only runs for PM status-changed dispatch, so SCM triggers are unaffected. Genuine Trello/JIRA/Linear projects keep pm.type exactly as before. Tests: NO_PM_PROVIDER + registry no-op, configMapper/schema leave pm undefined, github adapter dispatches an SCM-only project with withPMCredentials(undefined), plus updates to the previously trello-defaulting tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/agents/shared/promptContext.ts | 2 +- src/backends/secretBuilder.ts | 7 ++- src/backends/sidecarManager.ts | 5 +- src/config/schema.ts | 8 ++- src/db/repositories/configMapper.ts | 18 ++++-- src/friction/types.ts | 2 +- src/gadgets/pm/core/reportFriction.ts | 11 ++-- src/pm/no-pm-provider.ts | 56 +++++++++++++++++++ src/pm/registry.ts | 15 +++-- src/pm/types.ts | 5 +- src/router/config.ts | 4 +- src/triggers/shared/backlog-check.ts | 4 ++ src/triggers/shared/splitting-auto-chain.ts | 2 +- tests/helpers/factories.ts | 17 ++++++ .../integration/pm-provider-switching.test.ts | 12 ++-- tests/unit/backends/adapter.test.ts | 1 + tests/unit/backends/secretBuilder.test.ts | 4 +- tests/unit/config/schema.test.ts | 13 +++++ .../unit/db/repositories/configMapper.test.ts | 7 +++ tests/unit/pm/factory.test.ts | 8 ++- tests/unit/pm/lifecycle.test.ts | 5 +- tests/unit/pm/no-pm-provider.test.ts | 29 ++++++++++ tests/unit/router/adapters/github.test.ts | 10 ++++ tests/unit/router/config.test.ts | 4 +- 24 files changed, 211 insertions(+), 38 deletions(-) create mode 100644 src/pm/no-pm-provider.ts create mode 100644 tests/unit/pm/no-pm-provider.test.ts diff --git a/src/agents/shared/promptContext.ts b/src/agents/shared/promptContext.ts index a247e8c40..937f47197 100644 --- a/src/agents/shared/promptContext.ts +++ b/src/agents/shared/promptContext.ts @@ -100,7 +100,7 @@ export function buildPromptContext( ...listIds, backlogListId, workItemCreateContainerId, - pmType: pmProvider?.type, + pmType: pmProvider && pmProvider.type !== 'none' ? pmProvider.type : undefined, ...terminology, maxInFlightItems: project.maxInFlightItems ?? 1, ...(prContext && { diff --git a/src/backends/secretBuilder.ts b/src/backends/secretBuilder.ts index 0b66c1556..8c409f56e 100644 --- a/src/backends/secretBuilder.ts +++ b/src/backends/secretBuilder.ts @@ -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; } diff --git a/src/backends/sidecarManager.ts b/src/backends/sidecarManager.ts index ec27a67ea..668741543 100644 --- a/src/backends/sidecarManager.ts +++ b/src/backends/sidecarManager.ts @@ -273,7 +273,10 @@ async function withProjectPMCredentials( project: ProjectConfig, fn: () => Promise, ): Promise { - 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); } diff --git a/src/config/schema.ts b/src/config/schema.ts index e7b23689c..bb672e590 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -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(), diff --git a/src/db/repositories/configMapper.ts b/src/db/repositories/configMapper.ts index c993dfe3c..90be617a4 100644 --- a/src/db/repositories/configMapper.ts +++ b/src/db/repositories/configMapper.ts @@ -89,7 +89,7 @@ export interface ProjectConfigRaw { repo?: string; baseBranch: string; branchPrefix: string; - pm: { type: string }; + pm?: { type: string }; model?: string; agentModels?: Record; maxIterations?: number; @@ -232,7 +232,7 @@ function buildAgentEngineConfig( function buildBaseProjectFields( row: ProjectRow, - pmType: 'trello' | 'jira' | 'linear', + pmType: 'trello' | 'jira' | 'linear' | undefined, ): ProjectConfigRaw { return { id: row.id, @@ -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, @@ -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), diff --git a/src/friction/types.ts b/src/friction/types.ts index 4c07f2dc7..225600290 100644 --- a/src/friction/types.ts +++ b/src/friction/types.ts @@ -45,7 +45,7 @@ export interface FrictionProjectContext { id: string; name?: string; repo?: string; - pmType?: ProjectConfig['pm']['type']; + pmType?: NonNullable['type']; } export interface FrictionAgentContext { diff --git a/src/gadgets/pm/core/reportFriction.ts b/src/gadgets/pm/core/reportFriction.ts index 7fc795664..b208c1e74 100644 --- a/src/gadgets/pm/core/reportFriction.ts +++ b/src/gadgets/pm/core/reportFriction.ts @@ -71,7 +71,9 @@ function parseJsonRecord(value: string | undefined): Record { } function projectFromEnv(): ProjectConfig { - const pmType = process.env.CASCADE_PM_TYPE as ProjectConfig['pm']['type'] | undefined; + const pmType = process.env.CASCADE_PM_TYPE as + | NonNullable['type'] + | undefined; const base = { id: process.env.CASCADE_PROJECT_ID ?? 'unknown-project', orgId: process.env.CASCADE_ORG_ID ?? 'unknown-org', @@ -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: { @@ -93,7 +96,7 @@ function projectFromEnv(): ProjectConfig { }, } as ProjectConfig; } - if (base.pm.type === 'linear') { + if (base.pm?.type === 'linear') { return { ...base, linear: { diff --git a/src/pm/no-pm-provider.ts b/src/pm/no-pm-provider.ts new file mode 100644 index 000000000..840f83052 --- /dev/null +++ b/src/pm/no-pm-provider.ts @@ -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 { + 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(), +}; diff --git a/src/pm/registry.ts b/src/pm/registry.ts index 042c9d67a..3b90d09bd 100644 --- a/src/pm/registry.ts +++ b/src/pm/registry.ts @@ -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 { @@ -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); } } diff --git a/src/pm/types.ts b/src/pm/types.ts index 95900b778..bc3a02de0 100644 --- a/src/pm/types.ts +++ b/src/pm/types.ts @@ -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; diff --git a/src/router/config.ts b/src/router/config.ts index d85887231..0f61c5459 100644 --- a/src/router/config.ts +++ b/src/router/config.ts @@ -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; @@ -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, diff --git a/src/triggers/shared/backlog-check.ts b/src/triggers/shared/backlog-check.ts index b16120faa..dd4a31784 100644 --- a/src/triggers/shared/backlog-check.ts +++ b/src/triggers/shared/backlog-check.ts @@ -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); } diff --git a/src/triggers/shared/splitting-auto-chain.ts b/src/triggers/shared/splitting-auto-chain.ts index b541335aa..ed9f5aa46 100644 --- a/src/triggers/shared/splitting-auto-chain.ts +++ b/src/triggers/shared/splitting-auto-chain.ts @@ -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 }, diff --git a/tests/helpers/factories.ts b/tests/helpers/factories.ts index 91bef1334..c737b77bc 100644 --- a/tests/helpers/factories.ts +++ b/tests/helpers/factories.ts @@ -34,6 +34,23 @@ export function createMockProject(overrides?: Partial): 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 { + return { + id: 'test', + orgId: 'org-1', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + ...overrides, + } as ProjectConfig; +} + /** * Creates a mock JIRA project config. */ diff --git a/tests/integration/pm-provider-switching.test.ts b/tests/integration/pm-provider-switching.test.ts index ee6906cf0..ee7f889cf 100644 --- a/tests/integration/pm-provider-switching.test.ts +++ b/tests/integration/pm-provider-switching.test.ts @@ -21,6 +21,7 @@ import { } from '../../src/db/repositories/settingsRepository.js'; import { withPMProvider } from '../../src/pm/context.js'; import { createPMProvider } from '../../src/pm/index.js'; +import { NO_PM_PROVIDER } from '../../src/pm/no-pm-provider.js'; import { pmRegistry } from '../../src/pm/registry.js'; import { JiraStatusChangedTrigger } from '../../src/triggers/jira/status-changed.js'; import { TrelloStatusChangedTodoTrigger } from '../../src/triggers/trello/status-changed.js'; @@ -150,9 +151,9 @@ describe('PM Provider Switching (integration)', () => { expect(provider.type).toBe('jira'); }); - it('defaults to Trello when pm.type is not set', () => { - // A project config without a pm.type field should default to 'trello' - // via pmRegistry.createProvider() → pm.type ?? 'trello' + it('returns the no-op PM provider when pm is not set (SCM-only project)', () => { + // A project with no pm field is SCM-only — createPMProvider returns the + // NO_PM_PROVIDER sentinel (type 'none'), NOT a phantom Trello provider. const projectConfig = { id: 'test-project', orgId: 'test-org', @@ -162,11 +163,12 @@ describe('PM Provider Switching (integration)', () => { branchPrefix: 'feature/', agentModels: {}, agentIterations: {}, - // No pm field at all — the registry defaults to 'trello' + // No pm field at all — SCM-only. }; const provider = createPMProvider(projectConfig as Parameters[0]); - expect(provider.type).toBe('trello'); + expect(provider).toBe(NO_PM_PROVIDER); + expect(provider.type).toBe('none'); }); }); diff --git a/tests/unit/backends/adapter.test.ts b/tests/unit/backends/adapter.test.ts index f85210ad5..1a55df668 100644 --- a/tests/unit/backends/adapter.test.ts +++ b/tests/unit/backends/adapter.test.ts @@ -153,6 +153,7 @@ function makeProject(): ProjectConfig { repo: 'owner/repo', baseBranch: 'main', branchPrefix: 'feature/', + pm: { type: 'trello' }, trello: { boardId: 'b1', lists: {}, labels: {} }, }; } diff --git a/tests/unit/backends/secretBuilder.test.ts b/tests/unit/backends/secretBuilder.test.ts index 6075e065d..c0a8fbf34 100644 --- a/tests/unit/backends/secretBuilder.test.ts +++ b/tests/unit/backends/secretBuilder.test.ts @@ -110,10 +110,10 @@ describe('augmentProjectSecrets', () => { }); }); - it('injects CASCADE_PM_TYPE defaulting to trello', async () => { + it('omits CASCADE_PM_TYPE for SCM-only projects (no pm provider)', async () => { const project = makeProject(); const secrets = await augmentProjectSecrets(project, 'implementation', {} as AgentInput); - expect(secrets.CASCADE_PM_TYPE).toBe('trello'); + expect(secrets.CASCADE_PM_TYPE).toBeUndefined(); }); it('injects CASCADE_PM_TYPE from project.pm.type when set', async () => { diff --git a/tests/unit/config/schema.test.ts b/tests/unit/config/schema.test.ts index a0e7953d2..372b09d39 100644 --- a/tests/unit/config/schema.test.ts +++ b/tests/unit/config/schema.test.ts @@ -32,6 +32,19 @@ describe.concurrent('ProjectConfigSchema', () => { expect(result.branchPrefix).toBe('feature/'); }); + it('leaves pm undefined for an SCM-only project (no pm field, not defaulted to trello)', () => { + const config = { + id: 'scm-only', + orgId: 'default', + name: 'SCM Only', + repo: 'owner/repo', + // no pm, no trello/jira/linear — a GitHub/SCM-only project + }; + + const result = ProjectConfigSchema.parse(config); + expect(result.pm).toBeUndefined(); + }); + it('rejects invalid repo format', () => { const config = { id: 'test', diff --git a/tests/unit/db/repositories/configMapper.test.ts b/tests/unit/db/repositories/configMapper.test.ts index 7accdf15d..4216626a3 100644 --- a/tests/unit/db/repositories/configMapper.test.ts +++ b/tests/unit/db/repositories/configMapper.test.ts @@ -299,6 +299,13 @@ describe('mapProjectRow', () => { expect(result.pm.type).toBe('linear'); }); + it('leaves pm undefined when no PM integration config is provided (SCM-only project)', () => { + const result = mapProjectRow( + makeInput({ trelloConfig: undefined, jiraConfig: undefined, linearConfig: undefined }), + ); + expect(result.pm).toBeUndefined(); + }); + it('builds trello config with boardId, lists, labels', () => { const result = mapProjectRow(makeInput()); expect(result.trello?.boardId).toBe('board123'); diff --git a/tests/unit/pm/factory.test.ts b/tests/unit/pm/factory.test.ts index f5f92b680..e536085e6 100644 --- a/tests/unit/pm/factory.test.ts +++ b/tests/unit/pm/factory.test.ts @@ -73,6 +73,7 @@ import '../../../src/sentry/register.js'; // factory.ts was removed; createPMProvider is now an inline function in index.ts import { createPMProvider } from '../../../src/pm/index.js'; import { JiraPMProvider } from '../../../src/pm/jira/adapter.js'; +import { NO_PM_PROVIDER } from '../../../src/pm/no-pm-provider.js'; import { TrelloPMProvider } from '../../../src/pm/trello/adapter.js'; describe('pm/factory', () => { @@ -99,7 +100,7 @@ describe('pm/factory', () => { expect(provider.type).toBe('trello'); }); - it('returns TrelloPMProvider when pm.type is undefined (defaults to trello)', () => { + it('returns NO_PM_PROVIDER when pm is undefined (SCM-only project)', () => { const project: ProjectConfig = { id: 'proj1', orgId: 'org1', @@ -107,6 +108,7 @@ describe('pm/factory', () => { repo: 'owner/repo', baseBranch: 'main', branchPrefix: 'feature/', + // No pm — provider selection keys off pm.type, not the trello block. trello: { boardId: 'board123', labels: { processing: 'label-id' }, @@ -116,8 +118,8 @@ describe('pm/factory', () => { const provider = createPMProvider(project); - expect(TrelloPMProvider).toHaveBeenCalled(); - expect(provider.type).toBe('trello'); + expect(provider).toBe(NO_PM_PROVIDER); + expect(provider.type).toBe('none'); }); it('returns JiraPMProvider when pm.type is jira', () => { diff --git a/tests/unit/pm/lifecycle.test.ts b/tests/unit/pm/lifecycle.test.ts index 1f4aae361..079078cbb 100644 --- a/tests/unit/pm/lifecycle.test.ts +++ b/tests/unit/pm/lifecycle.test.ts @@ -154,7 +154,7 @@ describe('pm/lifecycle', () => { }); }); - it('defaults to Trello config when pm.type is undefined', () => { + it('returns an empty config when pm is undefined, even with a trello block (SCM-only)', () => { const project: ProjectConfig = { id: 'proj1', orgId: 'org1', @@ -171,7 +171,7 @@ describe('pm/lifecycle', () => { const config = resolveProjectPMConfig(project); - expect(config.labels.processing).toBe('label-id'); + expect(config).toEqual({ labels: {}, statuses: {} }); }); it('returns JIRA config with custom labels when configured', () => { @@ -273,6 +273,7 @@ describe('pm/lifecycle', () => { repo: 'owner/repo', baseBranch: 'main', branchPrefix: 'feature/', + pm: { type: 'trello' }, trello: { boardId: 'board123', labels: {}, diff --git a/tests/unit/pm/no-pm-provider.test.ts b/tests/unit/pm/no-pm-provider.test.ts new file mode 100644 index 000000000..f40bef8a2 --- /dev/null +++ b/tests/unit/pm/no-pm-provider.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { NO_PM_PROVIDER } from '../../../src/pm/no-pm-provider.js'; +import { pmRegistry } from '../../../src/pm/registry.js'; +import { createMockGitHubOnlyProject } from '../../helpers/factories.js'; + +describe('SCM-only projects (no PM provider)', () => { + it('pmRegistry.createProvider returns NO_PM_PROVIDER when project.pm is undefined', () => { + expect(pmRegistry.createProvider(createMockGitHubOnlyProject())).toBe(NO_PM_PROVIDER); + }); + + it('pmRegistry.resolveLifecycleConfig returns an empty config when project.pm is undefined', () => { + expect(pmRegistry.resolveLifecycleConfig(createMockGitHubOnlyProject())).toEqual({ + labels: {}, + statuses: {}, + }); + }); + + it('NO_PM_PROVIDER reports type "none"', () => { + expect(NO_PM_PROVIDER.type).toBe('none'); + }); + + it('NO_PM_PROVIDER PM operations throw a clear error', async () => { + await expect(NO_PM_PROVIDER.createWorkItem({ containerId: 'x', title: 't' })).rejects.toThrow( + /no PM provider/i, + ); + await expect(NO_PM_PROVIDER.moveWorkItem('1', 'dest')).rejects.toThrow(/no PM provider/i); + await expect(NO_PM_PROVIDER.getWorkItem('1')).rejects.toThrow(/no PM provider/i); + }); +}); diff --git a/tests/unit/router/adapters/github.test.ts b/tests/unit/router/adapters/github.test.ts index 40d36adb4..969595557 100644 --- a/tests/unit/router/adapters/github.test.ts +++ b/tests/unit/router/adapters/github.test.ts @@ -324,6 +324,16 @@ describe('GitHubRouterAdapter', () => { mockTriggerRegistry, ); expect(result?.agentType).toBe('review'); + + // SCM-only project (p1 has no `pm`): withPMCredentials receives an + // undefined pmType and short-circuits — no Trello credential is resolved. + const { withPMCredentials } = await import('../../../../src/pm/context.js'); + expect(vi.mocked(withPMCredentials)).toHaveBeenCalledWith( + 'p1', + undefined, + expect.any(Function), + expect.any(Function), + ); }); it('returns null when no full project found', async () => { diff --git a/tests/unit/router/config.test.ts b/tests/unit/router/config.test.ts index 9699447ec..fa7a13fcc 100644 --- a/tests/unit/router/config.test.ts +++ b/tests/unit/router/config.test.ts @@ -147,7 +147,7 @@ describe('loadProjectConfig', () => { }); }); - it('defaults pmType to trello when pm.type is not set', async () => { + it('leaves pmType undefined when pm is not set (SCM-only project)', async () => { mockLoadConfig.mockResolvedValueOnce({ projects: [ { @@ -165,7 +165,7 @@ describe('loadProjectConfig', () => { const { loadProjectConfig: freshLoad } = await import('../../../src/router/config.js'); const result = await freshLoad(); - expect(result.projects[0].pmType).toBe('trello'); + expect(result.projects[0].pmType).toBeUndefined(); }); it('caches config for subsequent calls within the TTL window', async () => { From 77f9a1474606a3bc681d3aeaee0b7b669c2e0ff7 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Mon, 22 Jun 2026 13:15:54 +0200 Subject: [PATCH 3/3] fix(pm): treat NO_PM_PROVIDER as "no provider" in prompt context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review: the SCM-only sentinel is truthy, so buildPromptContext still used it for workItemUrl and terminology — and NO_PM_PROVIDER.getWorkItemUrl() throws synchronously, boot-failing an SCM-only run that carries a workItemId (e.g. a stale pr_work_items row or a manual/retry path) before the agent runs. Normalize the sentinel to null once and use that value for workItemUrl, terminology, and pmType. Adds a regression test that buildPromptContext with NO_PM_PROVIDER in scope + a workItemId does not throw. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/agents/shared/promptContext.ts | 7 +++++- .../unit/agents/shared/promptContext.test.ts | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/agents/shared/promptContext.ts b/src/agents/shared/promptContext.ts index 937f47197..173c0a585 100644 --- a/src/agents/shared/promptContext.ts +++ b/src/agents/shared/promptContext.ts @@ -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); diff --git a/tests/unit/agents/shared/promptContext.test.ts b/tests/unit/agents/shared/promptContext.test.ts index fa33b8c51..e9ddde039 100644 --- a/tests/unit/agents/shared/promptContext.test.ts +++ b/tests/unit/agents/shared/promptContext.test.ts @@ -7,6 +7,7 @@ vi.mock('../../../../src/pm/index.js', () => ({ import { buildPromptContext } from '../../../../src/agents/shared/promptContext.js'; import { getPMProviderOrNull } from '../../../../src/pm/index.js'; +import { NO_PM_PROVIDER } from '../../../../src/pm/no-pm-provider.js'; import { createMockPMProvider } from '../../../helpers/mockPMProvider.js'; const mockGetPMProvider = vi.mocked(getPMProviderOrNull); @@ -622,4 +623,26 @@ describe('buildPromptContext', () => { expect(ctx.detectedAgentType).toBe('implementation'); }); }); + + describe('with SCM-only project (NO_PM_PROVIDER in scope)', () => { + beforeEach(() => { + mockGetPMProvider.mockReturnValue(NO_PM_PROVIDER); + }); + + it('does not throw and leaves workItemUrl/pmType undefined even with a workItemId', () => { + // Regression: NO_PM_PROVIDER.getWorkItemUrl() throws — promptContext must + // normalize the sentinel to "no provider" so an SCM-only run carrying a + // workItemId (stale pr_work_items row / manual/retry path) still boots. + let ctx!: ReturnType; + expect(() => { + ctx = buildPromptContext( + 'card123', + makeProject({ pm: undefined, trello: undefined }) as never, + ); + }).not.toThrow(); + expect(ctx.workItemUrl).toBeUndefined(); + expect(ctx.pmType).toBeUndefined(); + expect(ctx.workItemNoun).toBe('card'); + }); + }); });