diff --git a/CHANGELOG.md b/CHANGELOG.md index e26034ba..8f0a6670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,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). +- **PM integration plug-and-play (Linear migrated — all PM providers now on manifest).** `linearManifest` + `linearProviderWizard` complete the migration for all three PM providers. Linear uses the shared `makeHmacSha256Verifier({ headerName: 'linear-signature' })` factory. This plan also consolidates three divergent copies of Linear auth/label logic: `src/router/platformClients/linear.ts` and `src/router/bot-identity-resolvers.ts` both switch to the shared `linearAuthHeader` helper, and `src/pm/linear/adapter.ts::resolveLabelId` delegates to the shared `_shared/label-id-resolver`. The divergent copies that shipped the `Bearer`-prefix and silent-label-drop bugs are physically deleted from the codebase. `pm-wizard.tsx` collapses: with all 3 providers on the manifest, the non-manifest fallback path is gone — every PM provider renders via `ManifestProviderWizardSection`. `src/triggers/builtins.ts` is now manifest-only for PM (SCM + alerting still on legacy). Conformance harness runs 44 assertions (11 × TestProvider + Trello + JIRA + Linear). Same deferrals as 006/2 + 006/3: `bootstrap.ts` Linear registration stays until plan 006/5 migrates the ~dozen `pmRegistry.get(...)` callers. No operator-visible changes. Closes plan 006/4 of spec [006](docs/specs/006-pm-integration-plug-and-play.md). ### Added diff --git a/docs/plans/006-pm-integration-plug-and-play/4-migrate-linear.md b/docs/plans/006-pm-integration-plug-and-play/4-migrate-linear.md.done similarity index 90% rename from docs/plans/006-pm-integration-plug-and-play/4-migrate-linear.md rename to docs/plans/006-pm-integration-plug-and-play/4-migrate-linear.md.done index e164ac1f..2f7b4685 100644 --- a/docs/plans/006-pm-integration-plug-and-play/4-migrate-linear.md +++ b/docs/plans/006-pm-integration-plug-and-play/4-migrate-linear.md.done @@ -5,8 +5,8 @@ plan: 4 plan_slug: migrate-linear 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, 3-migrate-jira.md.done] +status: done --- # 006/4: Migrate Linear onto the PM provider manifest @@ -199,17 +199,19 @@ Automatic. Ensure manifest is imported before harness runs. ## Progress -- [ ] AC #1 Linear manifest registered -- [ ] AC #2 Conformance harness passes all three providers -- [ ] AC #3 Existing Linear tests green unchanged (modulo shared-helper adoption) -- [ ] AC #4 Wizard Linear branch removed -- [ ] AC #5 Legacy registration branches removed for Linear -- [ ] AC #6 Linear tRPC label endpoints consolidated -- [ ] AC #7 Platform clients + bot resolver use shared auth-header helper -- [ ] AC #8 Adapter delegates to shared label resolver -- [ ] AC #9 Operator-facing Linear behavior unchanged -- [ ] AC #10 All new code has tests -- [ ] AC #11 Build passes -- [ ] AC #12 Tests pass -- [ ] AC #13 Lint passes -- [ ] AC #14 Typecheck passes +- [x] AC #1 Linear manifest registered +- [x] AC #2 Conformance harness passes all three providers + TestProvider (44 tests — 11 × 4) +- [x] AC #3 Existing Linear tests green unchanged +- [x] AC #4 Wizard Linear branch removed — non-manifest fallback path deleted entirely (all 3 providers go through `ManifestProviderWizardSection`) +- [x] AC #5 Legacy registration branches removed — `builtins.ts` now manifest-only for PM; `worker-env.ts` extractor has no PM-specific branches +- [ ] AC #6 Linear tRPC label endpoints consolidated — **deferred to plan 006/5** (same reasoning as 006/2 + 006/3) +- [x] AC #7 Platform clients + bot resolver use `linearAuthHeader` from `_shared/auth-headers`; divergent in-file copies deleted +- [x] AC #8 Adapter delegates to shared `_shared/label-id-resolver.resolveLabelId`; private copy + `UUID_PATTERN` constant deleted +- [x] AC #9 Operator-facing Linear behavior unchanged — 7808/7808 tests pass +- [x] AC #10 All new code has tests (15 new Linear manifest tests) +- [x] AC #11 Build passes (backend + web) +- [x] AC #12 Tests pass (7808/7808) +- [x] AC #13 Lint passes +- [x] AC #14 Typecheck passes + +**Partial-state**: `src/integrations/bootstrap.ts` Linear registration retained — same reason as 006/2 + 006/3 (~dozen `pmRegistry.get(...)` callers to migrate). Plan 006/5 handles this. diff --git a/src/integrations/README.md b/src/integrations/README.md index 318a4d99..b2e1e0d3 100644 --- a/src/integrations/README.md +++ b/src/integrations/README.md @@ -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 (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. +> **Migration status (plan 006/5 pending — cleanup only):** +> **Trello: ✓ migrated** (006/2). **JIRA: ✓ migrated** (006/3). **Linear: ✓ migrated** (006/4). Every PM provider now registers through the manifest pattern; the shared conformance harness exercises all three alongside `TestProvider`. `src/integrations/bootstrap.ts` still registers all three in `pmRegistry` for backward compatibility with the ~dozen `pmRegistry.get(...)` call sites in webhook handlers, manual runners, and credential scoping. Plan 006/5 migrates those callers to `pmProviderRegistry.get(id)?.pmIntegration` and deletes the legacy registration paths atomically. --- diff --git a/src/integrations/pm/index.ts b/src/integrations/pm/index.ts index 82f930a4..ab4f79fe 100644 --- a/src/integrations/pm/index.ts +++ b/src/integrations/pm/index.ts @@ -8,3 +8,4 @@ import './trello/index.js'; import './jira/index.js'; +import './linear/index.js'; diff --git a/src/integrations/pm/linear/index.ts b/src/integrations/pm/linear/index.ts new file mode 100644 index 00000000..68b97e5e --- /dev/null +++ b/src/integrations/pm/linear/index.ts @@ -0,0 +1,10 @@ +/** + * Linear PM provider — side-effect module that registers the manifest. + */ + +import { registerPMProvider } from '../registry.js'; +import { linearManifest } from './manifest.js'; + +registerPMProvider(linearManifest); + +export { linearManifest }; diff --git a/src/integrations/pm/linear/manifest.ts b/src/integrations/pm/linear/manifest.ts new file mode 100644 index 00000000..1cc12ff3 --- /dev/null +++ b/src/integrations/pm/linear/manifest.ts @@ -0,0 +1,64 @@ +/** + * Linear PM provider manifest. + * + * Wires the existing Linear implementation into the PMProviderManifest + * contract. Linear signs webhook bodies with HMAC-SHA256 hex in the + * `linear-signature` header — no prefix — so the shared + * `makeHmacSha256Verifier` factory covers it directly. + * + * This plan (006/4) also migrates Linear's platform client + bot + * identity resolver to the canonical `linearAuthHeader` helper and the + * adapter's `resolveLabelId` to the shared `_shared/label-id-resolver`. + * See the companion src/router/platformClients/linear.ts and + * src/pm/linear/adapter.ts edits. + */ + +import { LinearIntegration } from '../../../pm/linear/integration.js'; +import { LinearRouterAdapter } from '../../../router/adapters/linear.js'; +import { LinearPlatformClient } from '../../../router/platformClients/linear.js'; +import { LinearCommentMentionTrigger } from '../../../triggers/linear/comment-mention.js'; +import { LinearReadyToProcessLabelTrigger } from '../../../triggers/linear/label-added.js'; +import { LinearStatusChangedTrigger } from '../../../triggers/linear/status-changed.js'; +import { makeHmacSha256Verifier } from '../_shared/webhook-verifier.js'; +import type { PMProviderManifest } from '../manifest.js'; + +const linearIntegration = new LinearIntegration(); + +export const linearManifest: PMProviderManifest = { + id: 'linear', + label: 'Linear', + category: 'pm', + + credentialRoles: [ + { role: 'api_key', label: 'API Key', envVarKey: 'LINEAR_API_KEY' }, + { + role: 'webhook_secret', + label: 'Webhook Secret', + envVarKey: 'LINEAR_WEBHOOK_SECRET', + optional: true, + }, + ], + + webhookRoute: '/linear/webhook', + verifyWebhookSignature: makeHmacSha256Verifier({ + headerName: 'linear-signature', + }), + + routerAdapter: new LinearRouterAdapter(), + + extractProjectIdFromJob: async (jobData) => { + const d = jobData as unknown as { type?: string; projectId?: string }; + if (d.type !== 'linear') return null; + return d.projectId ?? null; + }, + + pmIntegration: linearIntegration, + + triggerHandlers: [ + new LinearCommentMentionTrigger(), + new LinearStatusChangedTrigger(), + new LinearReadyToProcessLabelTrigger(), + ], + + platformClientFactory: (projectId) => new LinearPlatformClient(projectId), +}; diff --git a/src/pm/linear/adapter.ts b/src/pm/linear/adapter.ts index 9b65baff..b630317b 100644 --- a/src/pm/linear/adapter.ts +++ b/src/pm/linear/adapter.ts @@ -7,6 +7,7 @@ * (sub-issues), following the same pattern used by JiraPMProvider for subtasks. */ +import { resolveLabelId as sharedResolveLabelId } from '../../integrations/pm/_shared/label-id-resolver.js'; import { linearClient } from '../../linear/client.js'; import { logger } from '../../utils/logging.js'; import type { LinearConfig } from '../config.js'; @@ -22,8 +23,6 @@ import type { WorkItemLabel, } from '../types.js'; -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - export class LinearPMProvider implements PMProvider { readonly type = 'linear' as const; @@ -32,24 +31,17 @@ export class LinearPMProvider implements PMProvider { /** * Resolve a label slot name or raw ID to a Linear label UUID. * - * Linear's GraphQL API requires UUIDs for issueUpdate.labelIds and - * issueLabelCreate lookups. Returning a non-UUID string would silently - * fail server-side, so we short-circuit misconfigurations here with a - * diagnostic. Returns null when the input cannot be resolved to a UUID. + * Delegates to the shared `_shared/label-id-resolver` helper — single + * source of truth for the UUID validation rule. Returns null when the + * input cannot be resolved to a UUID; the caller then short-circuits + * the label operation with a visible warn. */ private resolveLabelId(slotOrId: string): string | null { - const mapped = (this.config.labels as Record | undefined)?.[slotOrId]; - const candidate = mapped ?? slotOrId; - if (UUID_PATTERN.test(candidate)) return candidate; - logger.warn( - '[Linear] Label value is not a UUID — skipping (check PM wizard → Label Mappings)', - { - input: slotOrId, - resolved: mapped ?? '', - teamId: this.config.teamId, - }, + return sharedResolveLabelId( + slotOrId, + this.config.labels as Record | undefined, + { providerId: 'linear', extra: { teamId: this.config.teamId } }, ); - return null; } async getWorkItem(id: string): Promise { diff --git a/src/router/bot-identity-resolvers.ts b/src/router/bot-identity-resolvers.ts index bc425e2b..6d380d8e 100644 --- a/src/router/bot-identity-resolvers.ts +++ b/src/router/bot-identity-resolvers.ts @@ -7,6 +7,7 @@ * Extracted from `acknowledgments.ts` to keep that module focused on ack CRUD. */ +import { linearAuthHeader } from '../integrations/pm/_shared/auth-headers.js'; import { BotIdentityCache } from './bot-identity.js'; import { resolveJiraCredentials, @@ -93,11 +94,7 @@ export async function resolveLinearBotUserId(projectId: string): Promise> { const response = await fetch(LINEAR_API_URL, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - // Linear personal API keys (lin_api_*) are sent bare; the `Bearer` prefix - // is only valid for OAuth tokens and triggers HTTP 400 with personal keys. - Authorization: apiKey, - }, + headers: linearAuthHeader(apiKey), body: JSON.stringify({ query, variables }), }); diff --git a/src/router/worker-env.ts b/src/router/worker-env.ts index e097b819..64c3c932 100644 --- a/src/router/worker-env.ts +++ b/src/router/worker-env.ts @@ -35,11 +35,8 @@ export async function extractProjectIdFromJob(data: CascadeJob): Promise { + await import('../../../../../src/integrations/pm/linear/index.js'); + const m = getPMProvider('linear'); + if (!m) throw new Error('linearManifest was not registered'); + manifest = m; +}); + +describe('linearManifest — identity', () => { + it("id is 'linear'", () => { + expect(manifest.id).toBe('linear'); + }); + + it("category is 'pm'", () => { + expect(manifest.category).toBe('pm'); + }); + + it("webhookRoute is '/linear/webhook'", () => { + expect(manifest.webhookRoute).toBe('/linear/webhook'); + }); +}); + +describe('linearManifest — credentialRoles', () => { + it('exposes api_key (required) and webhook_secret (optional)', () => { + const byRole = Object.fromEntries(manifest.credentialRoles.map((r) => [r.role, r])); + expect(byRole.api_key).toMatchObject({ role: 'api_key', envVarKey: 'LINEAR_API_KEY' }); + expect(byRole.api_key.optional).toBeFalsy(); + expect(byRole.webhook_secret).toMatchObject({ + role: 'webhook_secret', + envVarKey: 'LINEAR_WEBHOOK_SECRET', + optional: true, + }); + }); +}); + +describe('linearManifest — verifyWebhookSignature', () => { + const RAW_BODY = '{"action":"update","type":"Issue","data":{"id":"issue-1","stateId":"s-1"}}'; + const SECRET = 'linear-webhook-secret'; + + function validSignature(body: string, secret: string): string { + return createHmac('sha256', secret).update(body, 'utf8').digest('hex'); + } + + it('accepts a valid HMAC-SHA256 hex signature in the linear-signature header', () => { + const sig = validSignature(RAW_BODY, SECRET); + expect(manifest.verifyWebhookSignature(RAW_BODY, { 'linear-signature': sig }, SECRET)).toBe( + true, + ); + }); + + it('rejects a tampered body', () => { + const sig = validSignature(RAW_BODY, SECRET); + expect( + manifest.verifyWebhookSignature(`${RAW_BODY}tampered`, { 'linear-signature': sig }, SECRET), + ).toBe(false); + }); + + it('rejects when the linear-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('linearManifest — extractProjectIdFromJob', () => { + it("returns projectId for { type: 'linear', projectId }", async () => { + const job = { type: 'linear', 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 Linear job missing projectId', async () => { + const job = { type: 'linear' } as unknown as CascadeJob; + expect(await manifest.extractProjectIdFromJob(job)).toBeNull(); + }); +}); + +describe('linearManifest — 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 linear', () => { + expect(manifest.routerAdapter.type).toBe('linear'); + }); + + it('pmIntegration.type is linear', () => { + expect(manifest.pmIntegration.type).toBe('linear'); + }); + + it('triggerHandlers includes all linear built-in handlers', () => { + const names = manifest.triggerHandlers.map((h) => h.name); + expect(names).toEqual( + expect.arrayContaining([ + 'linear-comment-mention', + 'linear-status-changed', + 'linear-ready-to-process-label-added', + ]), + ); + }); +}); diff --git a/tests/unit/router/container-manager.test.ts b/tests/unit/router/container-manager.test.ts index 70fd9956..2ced742b 100644 --- a/tests/unit/router/container-manager.test.ts +++ b/tests/unit/router/container-manager.test.ts @@ -84,11 +84,12 @@ vi.mock('../../../src/router/config.js', () => ({ // --------------------------------------------------------------------------- import { findProjectByRepo, getAllProjectCredentials } from '../../../src/config/provider.js'; -// 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. +// All PM providers (Trello 006/2, JIRA 006/3, Linear 006/4) resolve via +// the PM provider manifest registry. Side-effect imports register them +// before the extractProjectIdFromJob assertions execute. import '../../../src/integrations/pm/trello/index.js'; import '../../../src/integrations/pm/jira/index.js'; +import '../../../src/integrations/pm/linear/index.js'; import { buildWorkerEnv, cleanupWorker, diff --git a/tests/unit/router/worker-env.test.ts b/tests/unit/router/worker-env.test.ts index 41550b00..7c98b0e8 100644 --- a/tests/unit/router/worker-env.test.ts +++ b/tests/unit/router/worker-env.test.ts @@ -41,10 +41,11 @@ vi.mock('../../../src/router/config.js', () => ({ // --------------------------------------------------------------------------- import { findProjectByRepo, getAllProjectCredentials } from '../../../src/config/provider.js'; -// Trello (006/2) and JIRA (006/3) resolve through the PM provider manifest -// registry. Side-effect imports register the manifests. +// All PM providers (Trello 006/2, JIRA 006/3, Linear 006/4) resolve through +// the PM provider manifest registry. Side-effect imports register them. import '../../../src/integrations/pm/trello/index.js'; import '../../../src/integrations/pm/jira/index.js'; +import '../../../src/integrations/pm/linear/index.js'; import type { CascadeJob } from '../../../src/router/queue.js'; import { buildWorkerEnv, diff --git a/tests/unit/triggers/builtins.test.ts b/tests/unit/triggers/builtins.test.ts index 72581e30..1d7a37fd 100644 --- a/tests/unit/triggers/builtins.test.ts +++ b/tests/unit/triggers/builtins.test.ts @@ -76,10 +76,10 @@ vi.mock('../../../src/triggers/linear/label-added.js', () => ({ .mockImplementation(() => ({ name: 'linear-ready-to-process-label-added' })), })); -// 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. +// After plans 006/2, 006/3, and 006/4, every PM provider's triggers are +// contributed via the 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: () => [ { @@ -102,6 +102,14 @@ vi.mock('../../../src/integrations/pm/registry.js', () => ({ { name: 'jira-label-added' }, ], }, + { + id: 'linear', + triggerHandlers: [ + { name: 'linear-comment-mention' }, + { name: 'linear-status-changed' }, + { name: 'linear-ready-to-process-label-added' }, + ], + }, ], })); diff --git a/web/src/components/projects/pm-providers/linear/adapters.tsx b/web/src/components/projects/pm-providers/linear/adapters.tsx new file mode 100644 index 00000000..badd1b3c --- /dev/null +++ b/web/src/components/projects/pm-providers/linear/adapters.tsx @@ -0,0 +1,64 @@ +/** + * Step-component adapters for Linear. + * + * Bridges the generic renderer's `{ state, dispatch, providerHooks }` + * props into the existing Linear step components' per-provider prop + * shapes. + */ + +import type { UseMutationResult } from '@tanstack/react-query'; +import { + LinearCredentialsStep, + LinearFieldMappingStep, + LinearTeamStep, +} from '../../pm-wizard-linear-steps.js'; +import type { ProviderWizardStepProps } from '../types.js'; + +export interface LinearProviderHooks { + readonly onTeamSelect: (id: string) => void; + readonly linearTeamsMutation: UseMutationResult; + readonly linearDetailsMutation: UseMutationResult; + readonly linearProjectsMutation: UseMutationResult; + readonly onCreateLabel: (slot: string) => void; + readonly onCreateAllMissingLabels: () => void; + readonly creatingSlot: string | null; +} + +function asLinearHooks(providerHooks: Record | undefined): LinearProviderHooks { + return (providerHooks ?? {}) as unknown as LinearProviderHooks; +} + +export function LinearCredentialsStepAdapter({ state, dispatch }: ProviderWizardStepProps) { + return ; +} + +export function LinearTeamStepAdapter({ state, dispatch, providerHooks }: ProviderWizardStepProps) { + const h = asLinearHooks(providerHooks); + return ( + + ); +} + +export function LinearFieldMappingStepAdapter({ + state, + dispatch, + providerHooks, +}: ProviderWizardStepProps) { + const h = asLinearHooks(providerHooks); + return ( + + ); +} diff --git a/web/src/components/projects/pm-providers/linear/index.ts b/web/src/components/projects/pm-providers/linear/index.ts new file mode 100644 index 00000000..8d90aa03 --- /dev/null +++ b/web/src/components/projects/pm-providers/linear/index.ts @@ -0,0 +1,10 @@ +/** + * Linear frontend wizard — side-effect registration. + */ + +import { registerProviderWizard } from '../registry.js'; +import { linearProviderWizard } from './wizard.js'; + +registerProviderWizard(linearProviderWizard); + +export { linearProviderWizard }; diff --git a/web/src/components/projects/pm-providers/linear/wizard.ts b/web/src/components/projects/pm-providers/linear/wizard.ts new file mode 100644 index 00000000..4c602c7b --- /dev/null +++ b/web/src/components/projects/pm-providers/linear/wizard.ts @@ -0,0 +1,111 @@ +/** + * Linear ProviderWizardDefinition. + * + * `useProviderHooks` composes the existing Linear hooks: + * `useLinearDiscovery` (teams + details + projects) and + * `useLinearLabelCreation` (single + batch label creation using the + * LINEAR_LABEL_DEFAULTS slot map). + * + * `buildIntegrationConfig` mirrors the inline Linear save body. + * Plan 006/5 will consolidate the save path onto the manifest's builder. + */ + +import { useState } from 'react'; +import { useLinearDiscovery, useLinearLabelCreation } from '../../pm-wizard-hooks.js'; +import { LINEAR_LABEL_DEFAULTS } from '../../pm-wizard-linear-steps.js'; +import { buildLinearIntegrationConfig } from '../../pm-wizard-state.js'; +import type { ProviderWizardDefinition } from '../types.js'; +import { + LinearCredentialsStepAdapter, + LinearFieldMappingStepAdapter, + LinearTeamStepAdapter, +} from './adapters.js'; + +function isCredentialsComplete(state: { + linearApiKey: string; + verificationResult: unknown; + isEditing: boolean; + hasStoredCredentials: boolean; +}): boolean { + if (state.isEditing && state.hasStoredCredentials) return true; + return Boolean(state.linearApiKey && state.verificationResult); +} + +export const linearProviderWizard: ProviderWizardDefinition = { + id: 'linear', + label: 'Linear', + + steps: [ + { + id: 'credentials', + title: 'Linear credentials', + Component: LinearCredentialsStepAdapter, + isComplete: isCredentialsComplete, + }, + { + id: 'team', + title: 'Team', + Component: LinearTeamStepAdapter, + isComplete: (state) => Boolean(state.linearTeamId), + }, + { + id: 'fields', + title: 'Field mappings', + Component: LinearFieldMappingStepAdapter, + isComplete: (state) => Object.keys(state.linearStatusMappings).length > 0, + }, + ], + + buildIntegrationConfig: buildLinearIntegrationConfig, + + isSetupComplete: (state) => { + if (!state.linearTeamId) return false; + if (Object.keys(state.linearStatusMappings).length === 0) return false; + return isCredentialsComplete(state); + }, + + useProviderHooks: ({ state, dispatch, projectId, advanceToStep }) => { + const discovery = useLinearDiscovery(state, dispatch, advanceToStep, projectId ?? ''); + const labels = useLinearLabelCreation(state, dispatch); + + const [creatingSlot, setCreatingSlot] = useState(null); + + const onCreateLabel = (slot: string) => { + const defaults = LINEAR_LABEL_DEFAULTS[slot]; + if (!defaults) return; + setCreatingSlot(slot); + labels.createLabelMutation.mutate( + { name: defaults.name, color: defaults.color, slot }, + { onSettled: () => setCreatingSlot(null) }, + ); + }; + + const onCreateAllMissingLabels = () => { + const existingLabelNames = new Set( + (state.linearTeamDetails?.labels ?? []).map((l) => l.name.toLowerCase()), + ); + const labelsToCreate = Object.entries(LINEAR_LABEL_DEFAULTS) + .filter(([slot, { name }]) => { + if (state.linearLabels[slot]) return false; + return !existingLabelNames.has(name.toLowerCase()); + }) + .map(([slot, { name, color }]) => ({ slot, name, color })); + if (labelsToCreate.length > 0) { + setCreatingSlot('__batch__'); + labels.createMissingLabelsMutation.mutate(labelsToCreate, { + onSettled: () => setCreatingSlot(null), + }); + } + }; + + return { + onTeamSelect: discovery.handleTeamSelect, + linearTeamsMutation: discovery.linearTeamsMutation, + linearDetailsMutation: discovery.linearDetailsMutation, + linearProjectsMutation: discovery.linearProjectsMutation, + onCreateLabel, + onCreateAllMissingLabels, + creatingSlot, + }; + }, +}; diff --git a/web/src/components/projects/pm-wizard.tsx b/web/src/components/projects/pm-wizard.tsx index 2e8d688c..87287235 100644 --- a/web/src/components/projects/pm-wizard.tsx +++ b/web/src/components/projects/pm-wizard.tsx @@ -3,17 +3,17 @@ import { CheckCircle, Globe, Loader2, XCircle } from 'lucide-react'; import { useEffect, useReducer, useRef, useState } from 'react'; import { Label } from '@/components/ui/label.js'; import { trpc } from '@/lib/trpc.js'; -// Side-effect imports register Trello (006/2) + JIRA (006/3) frontend -// wizards into the provider registry. Plan 006/4 will append linear. +// Side-effect imports register every PM provider's frontend wizard into +// the provider registry. With Linear migrated (006/4), every PM provider +// now renders via the manifest shell. import './pm-providers/trello/index.js'; import './pm-providers/jira/index.js'; +import './pm-providers/linear/index.js'; import { ManifestProviderWizardSection } from './pm-providers/manifest-section.js'; import { getProviderWizard } from './pm-providers/registry.js'; import { renderManifestStep } from './pm-providers/render.js'; import { SaveStep, WebhookStep } from './pm-wizard-common-steps.js'; import { - useLinearDiscovery, - useLinearLabelCreation, useLinearWebhookInfo, useSaveMutation, useVerification, @@ -23,12 +23,8 @@ import { // through the manifest path (see ./pm-providers/jira/). The // `pm-wizard-jira-steps` module is still imported transitively by the // adapters in `./pm-providers/jira/adapters.tsx`. -import { - LINEAR_LABEL_DEFAULTS, - LinearCredentialsStep, - LinearFieldMappingStep, - LinearTeamStep, -} from './pm-wizard-linear-steps.js'; +// Linear legacy step imports removed — all Linear wizard rendering flows +// through the manifest path (see ./pm-providers/linear/). import { areCredentialsReady, buildEditState, @@ -93,11 +89,9 @@ export function PMWizard({ const [state, dispatch] = useReducer(wizardReducer, undefined, createInitialState); const [openSteps, setOpenSteps] = useState>(new Set([1])); - const [creatingSlot, setCreatingSlot] = useState(null); - // Trello's creatingCostField was migrated into the provider wizard's own - // useProviderHooks; the parent no longer owns it. - // JIRA's creatingJiraCostField migrated into the provider wizard's - // useProviderHooks (plan 006/3). + // Provider-specific ephemeral state (creatingSlot, creatingCostField) now + // lives inside each provider's useProviderHooks — Trello 006/2, JIRA + // 006/3, Linear 006/4. The parent wizard no longer owns any. // ---- Step navigation helpers ---- @@ -143,15 +137,9 @@ export function PMWizard({ const manifestDef = getProviderWizard(state.provider); const { verifyMutation } = useVerification(state, dispatch, advanceToStep); - // Trello (006/2) and JIRA (006/3) discovery / label / custom-field hooks - // are composed inside each provider's useProviderHooks. Linear migrates - // in plan 006/4. - const { linearTeamsMutation, linearDetailsMutation, linearProjectsMutation, handleTeamSelect } = - useLinearDiscovery(state, dispatch, advanceToStep, projectId); - const { - createLabelMutation: createLinearLabelMutation, - createMissingLabelsMutation: createMissingLinearLabelsMutation, - } = useLinearLabelCreation(state, dispatch); + // Every PM provider (Trello 006/2, JIRA 006/3, Linear 006/4) composes its + // discovery / label / custom-field hooks inside its own useProviderHooks. + // The parent wizard no longer calls any provider-specific React hook. const webhookManagement = useWebhookManagement(projectId, state); const { webhookUrl: linearWebhookUrl } = useLinearWebhookInfo(); const { saveMutation } = useSaveMutation(projectId, state); @@ -160,37 +148,8 @@ export function PMWizard({ (c) => c.envVarKey === 'LINEAR_WEBHOOK_SECRET', ); - // ---- Label creation handlers ---- - // Trello (006/2) and JIRA (006/3) handlers migrated into their provider - // wizards' useProviderHooks. Linear follows in 006/4. - - const handleCreateLinearLabel = (slot: string) => { - const defaults = LINEAR_LABEL_DEFAULTS[slot]; - if (!defaults) return; - setCreatingSlot(slot); - createLinearLabelMutation.mutate( - { name: defaults.name, color: defaults.color, slot }, - { onSettled: () => setCreatingSlot(null) }, - ); - }; - - const handleCreateAllMissingLinearLabels = () => { - const existingLabelNames = new Set( - (state.linearTeamDetails?.labels ?? []).map((l) => l.name.toLowerCase()), - ); - const labelsToCreate = Object.entries(LINEAR_LABEL_DEFAULTS) - .filter(([slot, { name }]) => { - if (state.linearLabels[slot]) return false; - return !existingLabelNames.has(name.toLowerCase()); - }) - .map(([slot, { name, color }]) => ({ slot, name, color })); - if (labelsToCreate.length > 0) { - setCreatingSlot('__batch__'); - createMissingLinearLabelsMutation.mutate(labelsToCreate, { - onSettled: () => setCreatingSlot(null), - }); - } - }; + // Label creation + discovery handlers now live inside each provider's + // useProviderHooks (Trello 006/2, JIRA 006/3, Linear 006/4). // ---- Step status ---- @@ -254,7 +213,7 @@ export function PMWizard({ isOpen={openSteps.has(2)} onToggle={() => toggleStep(2)} > - {manifestDef ? ( + {manifestDef && ( - ) : ( - )}
@@ -306,7 +263,7 @@ export function PMWizard({ isOpen={openSteps.has(3)} onToggle={() => toggleStep(3)} > - {manifestDef ? ( + {manifestDef && ( - ) : ( - )} @@ -335,7 +283,7 @@ export function PMWizard({ isOpen={openSteps.has(4)} onToggle={() => toggleStep(4)} > - {manifestDef ? ( + {manifestDef && ( - ) : ( - )}