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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ All notable user-visible changes to CASCADE are documented here. The format is l

- **PM integration plug-and-play (infrastructure).** Introduced `PMProviderManifest` as the canonical per-provider contract — one object declares credentials, webhook route and verifier, router adapter, trigger handlers, platform client, job-id extractor, and optional label-creation hook. Landed `pmProviderRegistry`, a conformance test harness (`tests/unit/integrations/pm-conformance.test.ts`), shared helpers (`_shared/auth-headers.ts`, `_shared/webhook-verifier.ts`, `_shared/label-id-resolver.ts`, `_shared/project-id-extractor.ts`), a new `pm.discovery` tRPC router, and a frontend provider-wizard registry with a generic step renderer. Dormant in this release — Trello, JIRA, and Linear continue to register through the legacy path; they migrate onto the manifest in follow-up PRs. No operator-visible changes. Closes plan 006/1 of spec [006](docs/specs/006-pm-integration-plug-and-play.md).
- **PM integration plug-and-play (Trello migrated).** Trello's webhook signature verifier, router adapter, triggers, platform client, job-id extractor, wizard steps, and label/custom-field creation hooks are now composed via a single `trelloManifest` + `trelloProviderWizard`. Extended the `ProviderWizardDefinition` contract with an optional `useProviderHooks` field so provider-specific React hooks run inside a shell component — `ManifestProviderWizardSection` — rather than at the wizard root; this is how we satisfy the React rules-of-hooks while still keeping Trello's Discovery/LabelCreation/CustomFieldCreation hook composition per-provider. The conformance harness now exercises Trello alongside the test fixture (22 shared tests × provider). Trello's legacy registrations in `bootstrap.ts` stay for now because nine-plus call sites still use `pmRegistry.get('trello')` — plan 006/5 migrates those callers and deletes the legacy lines. No operator-visible changes. Closes plan 006/2 of spec [006](docs/specs/006-pm-integration-plug-and-play.md).
- **PM integration plug-and-play (JIRA migrated).** JIRA joins Trello on the manifest pattern with `jiraManifest` + `jiraProviderWizard`. `verifyWebhookSignature` uses the shared `makeHmacSha256Verifier` factory (Trello's bespoke scheme didn't fit, so this is the first consumer). Wizard steps + discovery / custom-field hooks moved into `jiraProviderWizard.useProviderHooks`; the JIRA-specific branches and hook instantiations are gone from `pm-wizard.tsx`. `worker-env.ts::extractProjectIdFromJob` JIRA branch removed (registry path handles it). Conformance harness now exercises Trello + JIRA + TestProvider (33 shared assertions × provider). Same deferrals as 006/2: `bootstrap.ts` JIRA registration stays until plan 006/5 migrates the `pmRegistry.get('jira')` callers. No operator-visible changes. Closes plan 006/3 of spec [006](docs/specs/006-pm-integration-plug-and-play.md).

### Added

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ plan: 3
plan_slug: migrate-jira
level: plan
parent_spec: docs/specs/006-pm-integration-plug-and-play.md
depends_on: [1-infrastructure.md]
status: pending
depends_on: [1-infrastructure.md.done, 2-migrate-trello.md.done]
status: done
---

# 006/3: Migrate JIRA onto the PM provider manifest
Expand Down Expand Up @@ -188,15 +188,17 @@ Automatic via `listPMProviders()` iteration. Ensure JIRA's manifest module is im
## Progress

<!-- /implement updates these as it works. Do not edit manually. -->
- [ ] AC #1 JIRA manifest registered
- [ ] AC #2 Conformance harness passes JIRA
- [ ] AC #3 Existing JIRA tests green unchanged
- [ ] AC #4 Wizard JIRA branch removed
- [ ] AC #5 Legacy registration branches removed for JIRA
- [ ] AC #6 JIRA tRPC endpoints consolidated
- [ ] AC #7 Operator-facing JIRA behavior unchanged
- [ ] AC #8 All new code has tests
- [ ] AC #9 Build passes
- [ ] AC #10 Tests pass
- [ ] AC #11 Lint passes
- [ ] AC #12 Typecheck passes
- [x] AC #1 JIRA manifest registered
- [x] AC #2 Conformance harness passes JIRA (33 tests — 11 × 3 providers)
- [x] AC #3 Existing JIRA tests green unchanged
- [x] AC #4 Wizard JIRA branch removed — both step renders and hook instantiations
- [x] AC #5 Legacy registration branches removed for JIRA — `builtins.ts`, `worker-env.ts` extractor
- [ ] AC #6 JIRA tRPC endpoints consolidated — **deferred to plan 006/5** (same reasoning as 006/2; `createJiraCustomField` stays for now)
- [x] AC #7 Operator-facing JIRA behavior unchanged — 7782/7782 tests pass; JIRA SSR tests green
- [x] AC #8 All new code has tests (16 new JIRA manifest tests)
- [x] AC #9 Build passes (backend + web)
- [x] AC #10 Tests pass (7782/7782)
- [x] AC #11 Lint passes
- [x] AC #12 Typecheck passes

**Partial-state**: `src/integrations/bootstrap.ts` JIRA registration retained — same reason as 006/2 (multiple `pmRegistry.get('jira')` callers still need migration, deferred to plan 006/5).
4 changes: 2 additions & 2 deletions src/integrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ CASCADE's PM providers (Trello, JIRA, Linear, and any future Asana/GitLab/ClickU

This document is the canonical guide for adding a new PM provider.

> **Migration status (plans 006/3–006/4 in flight):**
> **Trello: ✓ migrated** (plan 006/2). JIRA and Linear continue to register through the legacy path described at the bottom of this file until plans 006/3 and 006/4 merge. Trello's `pmRegistry` registration is kept in `src/integrations/bootstrap.ts` for now because many call sites still look up `pmRegistry.get('trello')`; plan 006/5 removes those callers and the bootstrap line together.
> **Migration status (plan 006/4 in flight):**
> **Trello: ✓ migrated** (plan 006/2). **JIRA: ✓ migrated** (plan 006/3). Linear still on the legacy path — plan 006/4. Trello's and JIRA's `pmRegistry` registrations are kept in `src/integrations/bootstrap.ts` for now because many call sites still look up `pmRegistry.get('trello' | 'jira')`; plan 006/5 removes those callers and the bootstrap lines together.

---

Expand Down
1 change: 1 addition & 0 deletions src/integrations/pm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
*/

import './trello/index.js';
import './jira/index.js';
10 changes: 10 additions & 0 deletions src/integrations/pm/jira/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* JIRA PM provider — side-effect module that registers the manifest.
*/

import { registerPMProvider } from '../registry.js';
import { jiraManifest } from './manifest.js';

registerPMProvider(jiraManifest);

export { jiraManifest };
67 changes: 67 additions & 0 deletions src/integrations/pm/jira/manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* JIRA PM provider manifest.
*
* Wires the existing JIRA implementation (JiraIntegration, JiraRouterAdapter,
* JIRA triggers, JiraPlatformClient) into the PMProviderManifest contract.
*
* Signing: JIRA uses `HMAC-SHA256(body)` with `sha256=<hex>` in the
* `X-Hub-Signature` header. This maps onto the shared
* `makeHmacSha256Verifier` factory landed in plan 006/1.
*
* Labels: JIRA labels are free-form names — the JIRA API auto-creates
* them on use. The shared `label-id-resolver` helper is NOT wired here;
* it's UUID-only. No `createLabel` manifest hook either for the same
* reason.
*/

import { JiraIntegration } from '../../../pm/jira/integration.js';
import { JiraRouterAdapter } from '../../../router/adapters/jira.js';
import { JiraPlatformClient } from '../../../router/platformClients/jira.js';
import { JiraCommentMentionTrigger } from '../../../triggers/jira/comment-mention.js';
import { JiraReadyToProcessLabelTrigger } from '../../../triggers/jira/label-added.js';
import { JiraStatusChangedTrigger } from '../../../triggers/jira/status-changed.js';
import { makeHmacSha256Verifier } from '../_shared/webhook-verifier.js';
import type { PMProviderManifest } from '../manifest.js';

const jiraIntegration = new JiraIntegration();

export const jiraManifest: PMProviderManifest = {
id: 'jira',
label: 'JIRA',
category: 'pm',

credentialRoles: [
{ role: 'email', label: 'Email', envVarKey: 'JIRA_EMAIL' },
{ role: 'api_token', label: 'API Token', envVarKey: 'JIRA_API_TOKEN' },
{
role: 'webhook_secret',
label: 'Webhook Secret',
envVarKey: 'JIRA_WEBHOOK_SECRET',
optional: true,
},
],

webhookRoute: '/jira/webhook',
verifyWebhookSignature: makeHmacSha256Verifier({
headerName: 'x-hub-signature',
headerPrefix: 'sha256=',
}),

routerAdapter: new JiraRouterAdapter(),

extractProjectIdFromJob: async (jobData) => {
const d = jobData as unknown as { type?: string; projectId?: string };
if (d.type !== 'jira') return null;
return d.projectId ?? null;
},

pmIntegration: jiraIntegration,

triggerHandlers: [
new JiraCommentMentionTrigger(),
new JiraStatusChangedTrigger(),
new JiraReadyToProcessLabelTrigger(),
],

platformClientFactory: (projectId) => new JiraPlatformClient(projectId),
};
7 changes: 3 additions & 4 deletions src/router/worker-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,9 @@ export async function extractProjectIdFromJob(data: CascadeJob): Promise<string
// Use type assertion since dashboard jobs are cast to CascadeJob
const jobData = data as unknown as { type: string; projectId?: string; repoFullName?: string };

// `trello` is now handled by the manifest registry (plan 006/2). The
// remaining `jira` and `linear` branches will move to the registry in
// plans 006/3 and 006/4.
if (jobData.type === 'jira' || jobData.type === 'linear') {
// `trello` (006/2) and `jira` (006/3) are handled by the manifest
// registry. The remaining `linear` branch migrates in plan 006/4.
if (jobData.type === 'linear') {
return jobData.projectId ?? null;
}
if (jobData.type === 'github') {
Expand Down
7 changes: 2 additions & 5 deletions src/triggers/builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,18 @@

import { listPMProviders } from '../integrations/pm/registry.js';
import { registerGitHubTriggers } from './github/register.js';
import { registerJiraTriggers } from './jira/register.js';
import { registerLinearTriggers } from './linear/register.js';
import type { TriggerRegistry } from './registry.js';
import { registerSentryTriggers } from './sentry/register.js';

export function registerBuiltInTriggers(registry: TriggerRegistry): void {
// Manifest-registered PM providers (Trello via 006/2; JIRA + Linear when
// plans 006/3 and 006/4 land) contribute their triggerHandlers here. The
// legacy `registerTrelloTriggers` etc. shrink as providers migrate.
// Manifest-registered PM providers (Trello via 006/2, JIRA via 006/3;
// Linear joins in 006/4) contribute their triggerHandlers here.
for (const manifest of listPMProviders()) {
for (const handler of manifest.triggerHandlers) {
registry.register(handler);
}
}
registerJiraTriggers(registry); // migrates in plan 006/3
registerLinearTriggers(registry); // migrates in plan 006/4
registerGitHubTriggers(registry);
registerSentryTriggers(registry);
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/integrations/pm-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import type { CascadeJob } from '../../../src/router/queue.js';
import { registerTestProvider } from '../../helpers/testPMProvider.js';

// Import every real PM provider so the harness exercises each of them
// alongside the TestProvider fixture. New providers migrated in plans
// 006/3 and 006/4 will add their own lines here.
// alongside the TestProvider fixture. Plan 006/4 will add linear.
import '../../../src/integrations/pm/trello/index.js';
import '../../../src/integrations/pm/jira/index.js';

// describe.each evaluates at collection time, before beforeAll. Register
// the TestProvider at module load so the iteration sees it.
Expand Down
134 changes: 134 additions & 0 deletions tests/unit/integrations/pm/jira/manifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* JIRA manifest — conformance + JIRA-specific behaviors.
*
* JIRA's signing scheme is plain HMAC-SHA256 over the raw body with a
* `sha256=` prefix, hex-encoded — this maps cleanly onto the shared
* `makeHmacSha256Verifier` factory from plan 006/1.
*
* The shared conformance harness at tests/unit/integrations/pm-conformance.test.ts
* already exercises every cross-cutting contract invariant against every
* registered provider; this file adds JIRA-specific behaviors.
*/

import { createHmac } from 'node:crypto';
import { beforeAll, describe, expect, it } from 'vitest';
import type { PMProviderManifest } from '../../../../../src/integrations/pm/manifest.js';
import { getPMProvider } from '../../../../../src/integrations/pm/registry.js';
import type { CascadeJob } from '../../../../../src/router/queue.js';

let manifest: PMProviderManifest;

beforeAll(async () => {
await import('../../../../../src/integrations/pm/jira/index.js');
const m = getPMProvider('jira');
if (!m) throw new Error('jiraManifest was not registered');
manifest = m;
});

describe('jiraManifest — identity', () => {
it("id is 'jira'", () => {
expect(manifest.id).toBe('jira');
});

it("category is 'pm'", () => {
expect(manifest.category).toBe('pm');
});

it("webhookRoute is '/jira/webhook'", () => {
expect(manifest.webhookRoute).toBe('/jira/webhook');
});
});

describe('jiraManifest — credentialRoles', () => {
it('exposes email + api_token (required) and webhook_secret (optional)', () => {
const byRole = Object.fromEntries(manifest.credentialRoles.map((r) => [r.role, r]));
expect(byRole.email).toMatchObject({ role: 'email', envVarKey: 'JIRA_EMAIL' });
expect(byRole.email.optional).toBeFalsy();
expect(byRole.api_token).toMatchObject({ role: 'api_token', envVarKey: 'JIRA_API_TOKEN' });
expect(byRole.api_token.optional).toBeFalsy();
expect(byRole.webhook_secret).toMatchObject({
role: 'webhook_secret',
envVarKey: 'JIRA_WEBHOOK_SECRET',
optional: true,
});
});

it("does NOT include base_url as a credential role (it's an integration-config field)", () => {
expect(manifest.credentialRoles.find((r) => r.role === 'base_url')).toBeUndefined();
});
});

describe('jiraManifest — verifyWebhookSignature', () => {
const RAW_BODY = '{"webhookEvent":"jira:issue_updated","issue":{"key":"PROJ-1"}}';
const SECRET = 'jira-webhook-secret';

function validSignature(body: string, secret: string): string {
return createHmac('sha256', secret).update(body, 'utf8').digest('hex');
}

it("accepts a valid signature of the form 'sha256=<hex>'", () => {
const sig = `sha256=${validSignature(RAW_BODY, SECRET)}`;
expect(manifest.verifyWebhookSignature(RAW_BODY, { 'x-hub-signature': sig }, SECRET)).toBe(
true,
);
});

it('rejects a tampered body', () => {
const sig = `sha256=${validSignature(RAW_BODY, SECRET)}`;
expect(
manifest.verifyWebhookSignature(`${RAW_BODY}tampered`, { 'x-hub-signature': sig }, SECRET),
).toBe(false);
});

it('rejects when the x-hub-signature header is missing', () => {
expect(manifest.verifyWebhookSignature(RAW_BODY, {}, SECRET)).toBe(false);
});

it('returns true (opt-out) when secret is null', () => {
expect(manifest.verifyWebhookSignature(RAW_BODY, {}, null)).toBe(true);
});
});

describe('jiraManifest — extractProjectIdFromJob', () => {
it("returns projectId for { type: 'jira', projectId }", async () => {
const job = { type: 'jira', projectId: 'proj-1' } as unknown as CascadeJob;
expect(await manifest.extractProjectIdFromJob(job)).toBe('proj-1');
});

it('returns null for a foreign job type', async () => {
const job = { type: 'github', projectId: 'proj-1' } as unknown as CascadeJob;
expect(await manifest.extractProjectIdFromJob(job)).toBeNull();
});

it('returns null for a JIRA job missing projectId', async () => {
const job = { type: 'jira' } as unknown as CascadeJob;
expect(await manifest.extractProjectIdFromJob(job)).toBeNull();
});
});

describe('jiraManifest — wiring', () => {
it('platformClientFactory returns an object with postComment + deleteComment', () => {
const client = manifest.platformClientFactory('proj-1');
expect(typeof client.postComment).toBe('function');
expect(typeof client.deleteComment).toBe('function');
});

it('routerAdapter.type is jira', () => {
expect(manifest.routerAdapter.type).toBe('jira');
});

it('pmIntegration.type is jira', () => {
expect(manifest.pmIntegration.type).toBe('jira');
});

it('triggerHandlers includes all jira built-in handlers', () => {
const names = manifest.triggerHandlers.map((h) => h.name);
expect(names).toEqual(
expect.arrayContaining([
'jira-comment-mention',
'jira-status-changed',
'jira-ready-to-process-label-added',
]),
);
});
});
7 changes: 4 additions & 3 deletions tests/unit/router/container-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,11 @@ vi.mock('../../../src/router/config.js', () => ({
// ---------------------------------------------------------------------------

import { findProjectByRepo, getAllProjectCredentials } from '../../../src/config/provider.js';
// Trello is resolved via the PM provider manifest as of plan 006/2. Import
// the trello barrel so the registration side effect runs before the
// extractProjectIdFromJob assertions execute.
// Trello (006/2) and JIRA (006/3) are resolved via the PM provider
// manifest registry. Side-effect imports register the manifests before
// the extractProjectIdFromJob assertions execute.
import '../../../src/integrations/pm/trello/index.js';
import '../../../src/integrations/pm/jira/index.js';
import {
buildWorkerEnv,
cleanupWorker,
Expand Down
5 changes: 3 additions & 2 deletions tests/unit/router/worker-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,10 @@ vi.mock('../../../src/router/config.js', () => ({
// ---------------------------------------------------------------------------

import { findProjectByRepo, getAllProjectCredentials } from '../../../src/config/provider.js';
// Trello is resolved through the PM provider manifest registry as of
// plan 006/2. Side-effect import registers the manifest.
// Trello (006/2) and JIRA (006/3) resolve through the PM provider manifest
// registry. Side-effect imports register the manifests.
import '../../../src/integrations/pm/trello/index.js';
import '../../../src/integrations/pm/jira/index.js';
import type { CascadeJob } from '../../../src/router/queue.js';
import {
buildWorkerEnv,
Expand Down
16 changes: 12 additions & 4 deletions tests/unit/triggers/builtins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,10 @@ vi.mock('../../../src/triggers/linear/label-added.js', () => ({
.mockImplementation(() => ({ name: 'linear-ready-to-process-label-added' })),
}));

// After plan 006/2, Trello's triggers are contributed to registerBuiltInTriggers
// via the PM provider manifest registry. Mock listPMProviders() to return a
// stub Trello manifest whose triggerHandlers preserve the exact names +
// ordering the rest of this test file asserts on.
// After plan 006/2 and 006/3, Trello and JIRA triggers are contributed to
// registerBuiltInTriggers via the PM provider manifest registry. Mock
// listPMProviders() to return stub manifests whose triggerHandlers
// preserve the exact names the rest of this test file asserts on.
vi.mock('../../../src/integrations/pm/registry.js', () => ({
listPMProviders: () => [
{
Expand All @@ -94,6 +94,14 @@ vi.mock('../../../src/integrations/pm/registry.js', () => ({
{ name: 'ready-to-process-label' },
],
},
{
id: 'jira',
triggerHandlers: [
{ name: 'jira-comment-mention' },
{ name: 'jira-status-changed' },
{ name: 'jira-label-added' },
],
},
],
}));

Expand Down
Loading
Loading