diff --git a/bin/cascade-tools.js b/bin/cascade-tools.js index cdce0f20..d822051d 100755 --- a/bin/cascade-tools.js +++ b/bin/cascade-tools.js @@ -31,7 +31,32 @@ import { Config, run } from '@oclif/core'; // runs commands lazily, and Spec 006/5 removed the legacy self-bootstrap // path, so side-effect imports have to fire at the entry point. // Without this, `cascade-tools pm ` throws `Unknown PM integration type`. -await import('../dist/cli/bootstrap.js'); +// +// 2026-05-10: agents in worker containers sometimes invoke this script as +// `node bin/cascade-tools.js …` from a fresh repo checkout where dist/ is +// not built (the installed `cascade-tools` binary in PATH is the right +// invocation). The raw ERR_MODULE_NOT_FOUND stack trace hid the recovery +// path from the agent on prod run ff6adf00. Trap that one specific case +// and emit a one-line stderr explainer instead. Any other module-not-found +// — e.g. a real missing dist file mid-bootstrap — propagates unchanged. +try { + await import('../dist/cli/bootstrap.js'); +} catch (err) { + if ( + err && + err.code === 'ERR_MODULE_NOT_FOUND' && + typeof err.message === 'string' && + /dist\/cli\/bootstrap/.test(err.message) + ) { + process.stderr.write( + 'cascade-tools: dist/ is not built in this checkout. ' + + 'Use the installed `cascade-tools` from PATH (already available in the worker container), ' + + 'or run `npm run build` first if you need to invoke `node bin/cascade-tools.js` directly.\n', + ); + process.exit(1); + } + throw err; +} // cascade-tools uses its own oclif config independent of package.json, // which now points to the dashboard CLI (cascade binary). diff --git a/src/friction/types.ts b/src/friction/types.ts index b62ea1fb..4c07f2dc 100644 --- a/src/friction/types.ts +++ b/src/friction/types.ts @@ -1,16 +1,23 @@ import type { ProjectConfig } from '../types/index.js'; -export type FrictionCategory = - | 'tooling' - | 'environment' - | 'permissions' - | 'dependency' - | 'test-failure' - | 'pm-data' - | 'scm-data' - | 'other'; +/** + * Free-form labels accepted as-is from the agent. + * + * Originally typed as a closed union (8 categories × 4 severities). Loosened + * on 2026-05-10 after prod run `ff6adf00` showed an agent recognizing a + * textbook friction (CASCADE_ORG_ID env-var leak in tests) but failing to + * file because oclif's enum gate rejected `--severity 'medium slowdown'` + * (the agent took the describe text literally — the gadget describe was + * `'Severity: low annoyance, medium slowdown, ...'`). The agent then crashed + * trying to discover correct values via `node bin/cascade-tools.js --help` + * because dist/ wasn't built in the workspace checkout, and gave up. + * + * Free-form for now lets the agent file any reasonable label. We can + * cluster + re-tighten after a week of real usage data. + */ +export type FrictionCategory = string; -export type FrictionSeverity = 'low' | 'medium' | 'high' | 'critical'; +export type FrictionSeverity = string; export interface FrictionReport { /** Stable id used for sidecar deduplication across queued/filed events. */ diff --git a/src/gadgets/pm/core/reportFriction.ts b/src/gadgets/pm/core/reportFriction.ts index 8899f0a3..7fc79566 100644 --- a/src/gadgets/pm/core/reportFriction.ts +++ b/src/gadgets/pm/core/reportFriction.ts @@ -4,11 +4,7 @@ import { appendFiledFrictionReport, appendQueuedFrictionReport, } from '../../../friction/sidecar.js'; -import type { - FrictionCategory, - FrictionReport, - FrictionSeverity, -} from '../../../friction/types.js'; +import type { FrictionReport } from '../../../friction/types.js'; import type { ProjectConfig } from '../../../types/index.js'; import { FRICTION_SIDECAR_ENV_VAR, @@ -30,29 +26,18 @@ import { const DEFAULT_FRICTION_SIDECAR_PATH = '.cascade/friction-reports.jsonl'; -const CATEGORIES = [ - 'tooling', - 'environment', - 'permissions', - 'dependency', - 'test-failure', - 'pm-data', - 'scm-data', - 'other', -] as const satisfies readonly FrictionCategory[]; - -const SEVERITIES = [ - 'low', - 'medium', - 'high', - 'critical', -] as const satisfies readonly FrictionSeverity[]; - export interface ReportFrictionParams { summary: string; details: string; - category: FrictionCategory; - severity: FrictionSeverity; + /** + * Free-form category and severity. Originally validated against an + * 8-member / 4-member enum, but loosened on 2026-05-10 (run ff6adf00) + * after agents took the gadget describe text literally and oclif + * rejected `--severity 'medium slowdown'`. Cluster + re-tighten later + * once we have real usage data. + */ + category: string; + severity: string; whileDoing?: string; project?: ProjectConfig; sidecarPath?: string; @@ -130,11 +115,6 @@ function projectFromEnv(): ProjectConfig { } as ProjectConfig; } -function requireEnum(value: string, allowed: readonly T[], name: string): T { - if ((allowed as readonly string[]).includes(value)) return value as T; - throw new Error(`${name} must be one of: ${allowed.join(', ')}`); -} - function parseOptionalInt(value: string | undefined): number | undefined { if (!value) return undefined; const parsed = Number.parseInt(value, 10); @@ -167,8 +147,8 @@ function buildReport(params: ReportFrictionParams, project: ProjectConfig): Fric reportId: randomUUID(), summary: params.summary, details: params.details, - category: requireEnum(params.category, CATEGORIES, 'category'), - severity: requireEnum(params.severity, SEVERITIES, 'severity'), + category: params.category, + severity: params.severity, whileDoing: params.whileDoing?.trim() || 'not specified', createdAt: new Date().toISOString(), context: { diff --git a/src/gadgets/pm/definitions.ts b/src/gadgets/pm/definitions.ts index 2965eeae..dd1d0e59 100644 --- a/src/gadgets/pm/definitions.ts +++ b/src/gadgets/pm/definitions.ts @@ -182,26 +182,14 @@ export const reportFrictionDef: ToolDefinition = { required: true, }, category: { - type: 'enum', - options: [ - 'tooling', - 'environment', - 'permissions', - 'dependency', - 'test-failure', - 'pm-data', - 'scm-data', - 'other', - ], + type: 'string', describe: - 'Friction category: tooling, environment, permissions, dependency, test-failure, pm-data, scm-data, or other', + 'Friction category (e.g. tooling, environment, permissions, dependency, test-failure, pm-data, scm-data, other)', required: true, }, severity: { - type: 'enum', - options: ['low', 'medium', 'high', 'critical'], - describe: - 'Severity: low annoyance, medium slowdown, high blocker risk, or critical hard blocker', + type: 'string', + describe: 'Severity (e.g. low, medium, high, critical)', required: true, }, whileDoing: { diff --git a/tests/unit/backends/toolManifests.test.ts b/tests/unit/backends/toolManifests.test.ts index d941952e..0dae5b39 100644 --- a/tests/unit/backends/toolManifests.test.ts +++ b/tests/unit/backends/toolManifests.test.ts @@ -48,7 +48,12 @@ describe('getToolManifests', () => { expect(names).toContain('PMDeleteChecklistItem'); }); - it('ReportFriction has enum category/severity and details-file support', () => { + it('ReportFriction has free-form string category/severity and details-file support', () => { + // 2026-05-10: category/severity were originally enum-typed but + // loosened to free-form strings after prod run `ff6adf00` showed + // agents misreading the gadget describe text. The manifest already + // emitted `type: 'string'` for these (manifest generator coerces + // enum → string); the underlying gadget def now matches. const manifests = getToolManifests(); const reportFriction = manifests.find((m) => m.name === 'ReportFriction'); expect(reportFriction).toBeDefined(); @@ -60,6 +65,11 @@ describe('getToolManifests', () => { severity: { type: 'string', required: true }, 'details-file': { type: 'string' }, }); + // Pin no `options` array on the manifest — proves the loosening + // reached the agent-facing surface. + const params = reportFriction?.parameters as Record; + expect(params.category.options).toBeUndefined(); + expect(params.severity.options).toBeUndefined(); }); it('includes GitHub PR tools', () => { diff --git a/tests/unit/gadgets/pm/core/reportFriction.test.ts b/tests/unit/gadgets/pm/core/reportFriction.test.ts index d73f6ad7..7c6c1030 100644 --- a/tests/unit/gadgets/pm/core/reportFriction.test.ts +++ b/tests/unit/gadgets/pm/core/reportFriction.test.ts @@ -149,17 +149,37 @@ describe('reportFriction', () => { rmSync(path, { force: true }); }); - it('validates category and severity in the core function', async () => { - await expect( - reportFriction({ - project, - sidecarPath: sidecarPath(), - summary: 'Bad category', - details: 'Invalid classification.', - category: 'invalid' as never, - severity: 'medium', - }), - ).rejects.toThrow('category must be one of'); + it('accepts any string for category and severity (loosened 2026-05-10 — see plan)', async () => { + // Originally enforced an enum (8 categories × 4 severities) via + // `requireEnum`. Loosened after prod run `ff6adf00` showed an agent + // taking the gadget describe text literally and oclif rejecting + // `--severity 'medium slowdown'`. We now pass through whatever the + // agent provides; cluster + re-tighten once we have real usage data. + const path = sidecarPath(); + const result = await reportFriction({ + project, + sidecarPath: path, + summary: 'Quirky friction', + details: 'Agent invented a label that used to reject.', + category: 'something-not-in-the-old-enum', + severity: 'medium slowdown', + }); + + // Either filed (if materializer succeeds in this test setup) or + // queued_slot_missing (if the project under test lacks a friction + // slot). Both prove the validation gate no longer rejects. + expect(['filed', 'queued_slot_missing', 'queued_for_retry']).toContain(result.status); + + // The queued sidecar event records the values verbatim — pin that + // the report stored what the agent passed. + const events = readFileSync(path, 'utf-8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + const queued = events.find((e) => e.event === 'queued'); + expect(queued?.report.category).toBe('something-not-in-the-old-enum'); + expect(queued?.report.severity).toBe('medium slowdown'); + rmSync(path, { force: true }); }); it('uses SessionState for run/work-item/PR metadata when env vars are absent (LLMist in-process path)', async () => { diff --git a/tests/unit/gadgets/pm/definitions.test.ts b/tests/unit/gadgets/pm/definitions.test.ts index 2d42f265..e09a9b44 100644 --- a/tests/unit/gadgets/pm/definitions.test.ts +++ b/tests/unit/gadgets/pm/definitions.test.ts @@ -210,27 +210,31 @@ describe('PM gadget definitions', () => { expect(reportFrictionDef.parameters.severity?.required).toBe(true); }); - it('category and severity are enums with expected options', () => { + it('category and severity accept any string with example values surfaced via describe', () => { + // 2026-05-10: deliberately loosened from `type: 'enum'` after prod run + // `ff6adf00` showed an agent recognizing a textbook friction but + // failing to file because oclif's enum gate rejected + // `--severity 'medium slowdown'` (the agent took the prior describe + // text "Severity: low annoyance, medium slowdown, ..." literally). + // Free-form for now; cluster + re-tighten once we have real usage data. const category = reportFrictionDef.parameters.category; const severity = reportFrictionDef.parameters.severity; - expect(category?.type).toBe('enum'); - expect((category as { options?: string[] }).options).toEqual([ - 'tooling', - 'environment', - 'permissions', - 'dependency', - 'test-failure', - 'pm-data', - 'scm-data', - 'other', - ]); - expect(severity?.type).toBe('enum'); - expect((severity as { options?: string[] }).options).toEqual([ - 'low', - 'medium', - 'high', - 'critical', - ]); + + expect(category?.type).toBe('string'); + expect(severity?.type).toBe('string'); + + // Pin the explicit absence of `options` so a future revert to enum + // fails this test loudly. + expect((category as { options?: unknown }).options).toBeUndefined(); + expect((severity as { options?: unknown }).options).toBeUndefined(); + + // Pin the new describe text — values listed inside parentheses + // instead of mixed with prose, which is what made the agent take + // the description as the literal value. + expect(category?.describe).toBe( + 'Friction category (e.g. tooling, environment, permissions, dependency, test-failure, pm-data, scm-data, other)', + ); + expect(severity?.describe).toBe('Severity (e.g. low, medium, high, critical)'); }); it('has details file input alternative', () => { diff --git a/tests/unit/web/jira-wizard-generator.test.ts b/tests/unit/web/jira-wizard-generator.test.ts index 0a312873..cbc180cb 100644 --- a/tests/unit/web/jira-wizard-generator.test.ts +++ b/tests/unit/web/jira-wizard-generator.test.ts @@ -5,6 +5,9 @@ * This file is the new coverage for the migrated wizard definition. */ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { jiraManifest } from '../../../src/integrations/pm/jira/manifest.js'; import { @@ -14,6 +17,19 @@ import { import { IssueTypeMappingStep } from '../../../web/src/components/projects/pm-providers/jira/issue-type-step.js'; import { jiraProviderWizard } from '../../../web/src/components/projects/pm-providers/jira/wizard.js'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const REPO_ROOT = resolve(__dirname, '..', '..', '..'); +const JIRA_HOOKS_PATH = resolve( + REPO_ROOT, + 'web/src/components/projects/pm-providers/jira/hooks.ts', +); +const JIRA_WIZARD_PATH = resolve( + REPO_ROOT, + 'web/src/components/projects/pm-providers/jira/wizard.ts', +); +const PM_WIZARD_HOOKS_PATH = resolve(REPO_ROOT, 'web/src/components/projects/pm-wizard-hooks.ts'); + describe('JIRA wizardSpec through the shared generator (post plan 011/3)', () => { it('each declared standard step dispatches to the corresponding real shared component', () => { const steps = jiraManifest.wizardSpec?.steps ?? []; @@ -82,3 +98,21 @@ describe('jiraProviderWizard (post plan 011/3)', () => { expect(jiraProviderWizard.useProviderHooks).toBeDefined(); }); }); + +describe('jiraProviderWizard provider-owned hooks', () => { + it('imports JIRA-specific hooks from the JIRA provider folder', () => { + const source = readFileSync(JIRA_WIZARD_PATH, 'utf8'); + expect(source).toContain("} from './hooks.js'"); + expect(source).not.toContain("} from '../../pm-wizard-hooks.js'"); + }); + + it('keeps JIRA-specific hook definitions out of the shared wizard hook module', () => { + const jiraHooks = readFileSync(JIRA_HOOKS_PATH, 'utf8'); + const sharedHooks = readFileSync(PM_WIZARD_HOOKS_PATH, 'utf8'); + + for (const hookName of ['useJiraDiscovery', 'useJiraCustomFieldCreation']) { + expect(jiraHooks).toContain(`export function ${hookName}`); + expect(sharedHooks).not.toContain(`export function ${hookName}`); + } + }); +}); diff --git a/tests/unit/web/linear-wizard-generator.test.ts b/tests/unit/web/linear-wizard-generator.test.ts index d1d11b7c..a436f1b1 100644 --- a/tests/unit/web/linear-wizard-generator.test.ts +++ b/tests/unit/web/linear-wizard-generator.test.ts @@ -19,6 +19,11 @@ const LINEAR_WIZARD_PATH = resolve( REPO_ROOT, 'web/src/components/projects/pm-providers/linear/wizard.ts', ); +const LINEAR_HOOKS_PATH = resolve( + REPO_ROOT, + 'web/src/components/projects/pm-providers/linear/hooks.ts', +); +const PM_WIZARD_HOOKS_PATH = resolve(REPO_ROOT, 'web/src/components/projects/pm-wizard-hooks.ts'); import { linearManifest } from '../../../src/integrations/pm/linear/manifest.js'; import { @@ -114,3 +119,21 @@ describe('linearProviderWizard — webhook URL construction guard', () => { ).not.toContain('/webhooks/'); }); }); + +describe('linearProviderWizard provider-owned hooks', () => { + it('imports Linear-specific hooks from the Linear provider folder', () => { + const source = readFileSync(LINEAR_WIZARD_PATH, 'utf8'); + expect(source).toContain("} from './hooks.js'"); + expect(source).not.toContain("} from '../../pm-wizard-hooks.js'"); + }); + + it('keeps Linear-specific hook definitions out of the shared wizard hook module', () => { + const linearHooks = readFileSync(LINEAR_HOOKS_PATH, 'utf8'); + const sharedHooks = readFileSync(PM_WIZARD_HOOKS_PATH, 'utf8'); + + for (const hookName of ['useLinearDiscovery', 'useLinearLabelCreation']) { + expect(linearHooks).toContain(`export function ${hookName}`); + expect(sharedHooks).not.toContain(`export function ${hookName}`); + } + }); +}); diff --git a/tests/unit/web/trello-wizard-generator.test.ts b/tests/unit/web/trello-wizard-generator.test.ts index 91f88465..b8e61d0a 100644 --- a/tests/unit/web/trello-wizard-generator.test.ts +++ b/tests/unit/web/trello-wizard-generator.test.ts @@ -8,6 +8,9 @@ * at render time. This test locks in both paths. */ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { trelloManifest } from '../../../src/integrations/pm/trello/manifest.js'; import { @@ -17,6 +20,19 @@ import { import { TrelloOAuthStep } from '../../../web/src/components/projects/pm-providers/trello/oauth-step.js'; import { trelloProviderWizard } from '../../../web/src/components/projects/pm-providers/trello/wizard.js'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const REPO_ROOT = resolve(__dirname, '..', '..', '..'); +const TRELLO_HOOKS_PATH = resolve( + REPO_ROOT, + 'web/src/components/projects/pm-providers/trello/hooks.ts', +); +const TRELLO_WIZARD_PATH = resolve( + REPO_ROOT, + 'web/src/components/projects/pm-providers/trello/wizard.ts', +); +const PM_WIZARD_HOOKS_PATH = resolve(REPO_ROOT, 'web/src/components/projects/pm-wizard-hooks.ts'); + describe('Trello wizardSpec through the shared generator (post plan 011/2)', () => { it('each declared standard step dispatches to the corresponding real shared component', () => { const steps = trelloManifest.wizardSpec?.steps ?? []; @@ -74,3 +90,25 @@ describe('trelloProviderWizard (post plan 011/2)', () => { expect(trelloProviderWizard.steps[0]?.Component).toBe(TrelloOAuthStep); }); }); + +describe('trelloProviderWizard provider-owned hooks', () => { + it('imports Trello-specific hooks from the Trello provider folder', () => { + const source = readFileSync(TRELLO_WIZARD_PATH, 'utf8'); + expect(source).toContain("} from './hooks.js'"); + expect(source).not.toContain("} from '../../pm-wizard-hooks.js'"); + }); + + it('keeps Trello-specific hook definitions out of the shared wizard hook module', () => { + const trelloHooks = readFileSync(TRELLO_HOOKS_PATH, 'utf8'); + const sharedHooks = readFileSync(PM_WIZARD_HOOKS_PATH, 'utf8'); + + for (const hookName of [ + 'useTrelloDiscovery', + 'useTrelloLabelCreation', + 'useTrelloCustomFieldCreation', + ]) { + expect(trelloHooks).toContain(`export function ${hookName}`); + expect(sharedHooks).not.toContain(`export function ${hookName}`); + } + }); +}); diff --git a/web/src/components/projects/pm-providers/jira/hooks.ts b/web/src/components/projects/pm-providers/jira/hooks.ts new file mode 100644 index 00000000..037712e4 --- /dev/null +++ b/web/src/components/projects/pm-providers/jira/hooks.ts @@ -0,0 +1,150 @@ +import { useMutation } from '@tanstack/react-query'; +import type { Dispatch } from 'react'; +import { useEffect } from 'react'; +import { trpcClient } from '@/lib/trpc.js'; +import { useProviderCustomFieldCreation } from '../../pm-wizard-hooks.js'; +import type { WizardAction, WizardState } from '../../pm-wizard-state.js'; + +// ============================================================================ +// JIRA Discovery +// ============================================================================ + +export function useJiraDiscovery( + state: WizardState, + dispatch: Dispatch, + advanceToStep: (step: number) => void, + projectId: string, +) { + const jiraProjectsMutation = useMutation({ + mutationFn: async () => { + // Plan 010/2: routes through generic pm.discovery.discover. + if (state.isEditing && state.hasStoredCredentials && !state.jiraEmail) { + const projects = (await trpcClient.pm.discovery.discover.mutate({ + providerId: 'jira', + capability: 'projects', + args: {}, + projectId, + })) as Array<{ id: string; name: string }>; + // Legacy shape has `key` — pm.discover returns `id` containing + // the JIRA key. Normalize for downstream consumers. + return projects.map((p) => ({ key: p.id, name: p.name })); + } + if (!state.jiraEmail || !state.jiraApiToken) { + throw new Error('Enter both credentials before fetching projects'); + } + const projects = (await trpcClient.pm.discovery.discover.mutate({ + providerId: 'jira', + capability: 'projects', + args: {}, + credentials: { + email: state.jiraEmail, + api_token: state.jiraApiToken, + base_url: state.jiraBaseUrl, + }, + })) as Array<{ id: string; name: string }>; + return projects.map((p) => ({ key: p.id, name: p.name })); + }, + onSuccess: (projects) => dispatch({ type: 'SET_JIRA_PROJECTS', projects }), + }); + + const jiraDetailsMutation = useMutation({ + mutationFn: (projectKey: string) => { + if (state.isEditing && state.hasStoredCredentials && !state.jiraEmail) { + return trpcClient.integrationsDiscovery.jiraProjectDetailsByProject.mutate({ + projectId, + projectKey, + }); + } + if (!state.jiraEmail || !state.jiraApiToken) { + throw new Error('Enter both credentials before fetching project details'); + } + return trpcClient.integrationsDiscovery.jiraProjectDetails.mutate({ + email: state.jiraEmail, + apiToken: state.jiraApiToken, + baseUrl: state.jiraBaseUrl, + projectKey, + }); + }, + onSuccess: (details) => { + dispatch({ type: 'SET_JIRA_PROJECT_DETAILS', details }); + advanceToStep(4); + }, + }); + + const handleProjectSelect = (key: string) => { + dispatch({ type: 'SET_JIRA_PROJECT_KEY', key }); + if (key) { + jiraDetailsMutation.mutate(key); + } + }; + + // Auto-fetch projects when verification result changes + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger only on verification result change + useEffect(() => { + if (!state.verificationResult || state.provider !== 'jira') return; + if (state.jiraProjects.length === 0 && !jiraProjectsMutation.isPending) { + jiraProjectsMutation.mutate(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.verificationResult]); + + // In edit mode, auto-fetch project list and details + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger on edit mode and stored creds + useEffect(() => { + if (!state.isEditing || state.provider !== 'jira') return; + const canFetch = state.jiraEmail ? !!state.jiraApiToken : state.hasStoredCredentials; + if (canFetch && state.jiraProjects.length === 0 && !jiraProjectsMutation.isPending) { + jiraProjectsMutation.mutate(); + } + if ( + state.jiraProjectKey && + !state.jiraProjectDetails && + canFetch && + !jiraDetailsMutation.isPending + ) { + jiraDetailsMutation.mutate(state.jiraProjectKey); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.isEditing, state.jiraProjectKey, state.hasStoredCredentials]); + + return { jiraProjectsMutation, jiraDetailsMutation, handleProjectSelect }; +} + +// ============================================================================ +// JIRA Custom Field Creation +// ============================================================================ + +export function useJiraCustomFieldCreation( + state: WizardState, + dispatch: Dispatch, + projectId: string, +) { + const inner = useProviderCustomFieldCreation( + { + providerId: 'jira', + // JIRA fields are global; containerId is sent as-is for uniform shape + getContainerId: (s) => s.jiraProjectKey || 'global', + addCustomField: (f) => ({ + type: 'ADD_JIRA_PROJECT_CUSTOM_FIELD', + field: { ...f, custom: true }, + }), + setCostField: (id) => ({ type: 'SET_JIRA_COST_FIELD', id }), + onError: (error) => { + console.error('Failed to create JIRA custom field:', error); + const message = error instanceof Error ? error.message : String(error); + if (message.includes('403') || message.toLowerCase().includes('admin')) { + alert( + 'Failed to create custom field: JIRA admin permissions are required to create global custom fields. Please contact your JIRA administrator.', + ); + } else { + alert(`Failed to create JIRA custom field: ${message}`); + } + }, + }, + state, + dispatch, + projectId, + ); + // Preserve the legacy export name for JIRA callers. + return { createJiraCustomFieldMutation: inner.createCustomFieldMutation }; +} diff --git a/web/src/components/projects/pm-providers/jira/wizard.ts b/web/src/components/projects/pm-providers/jira/wizard.ts index 7a278cff..5dc94eba 100644 --- a/web/src/components/projects/pm-providers/jira/wizard.ts +++ b/web/src/components/projects/pm-providers/jira/wizard.ts @@ -20,7 +20,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { ReactElement } from 'react'; import { API_URL } from '@/lib/api.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; -import { useJiraCustomFieldCreation, useJiraDiscovery } from '../../pm-wizard-hooks.js'; import { deriveActiveWebhooks } from '../../pm-wizard-state.js'; import { ContainerPickStep } from '../steps/container-pick.js'; import { CredentialsStep } from '../steps/credentials.js'; @@ -28,6 +27,7 @@ import { CustomFieldMappingStep } from '../steps/custom-field-mapping.js'; import { LabelMappingStep } from '../steps/label-mapping.js'; import { StatusMappingStep } from '../steps/status-mapping.js'; import type { ProviderWizardDefinition, ProviderWizardStepProps } from '../types.js'; +import { useJiraCustomFieldCreation, useJiraDiscovery } from './hooks.js'; import { IssueTypeMappingStep } from './issue-type-step.js'; import { JiraWebhookAdapter } from './webhook-step.js'; diff --git a/web/src/components/projects/pm-providers/linear/hooks.ts b/web/src/components/projects/pm-providers/linear/hooks.ts new file mode 100644 index 00000000..5644fcd3 --- /dev/null +++ b/web/src/components/projects/pm-providers/linear/hooks.ts @@ -0,0 +1,174 @@ +import { useMutation } from '@tanstack/react-query'; +import type { Dispatch } from 'react'; +import { useEffect } from 'react'; +import { trpcClient } from '@/lib/trpc.js'; +import { useProviderLabelCreation } from '../../pm-wizard-hooks.js'; +import type { + LinearProjectOption, + LinearTeamDetails, + LinearTeamOption, + WizardAction, + WizardState, +} from '../../pm-wizard-state.js'; + +// ============================================================================ +// Linear Discovery +// ============================================================================ + +export function useLinearDiscovery( + state: WizardState, + dispatch: Dispatch, + advanceToStep: (step: number) => void, + projectId: string, +) { + const linearTeamsMutation = useMutation({ + mutationFn: async () => { + // Plan 010/2: routes through generic pm.discovery.discover. + if (state.isEditing && state.hasStoredCredentials && !state.linearApiKey) { + return (await trpcClient.pm.discovery.discover.mutate({ + providerId: 'linear', + capability: 'teams', + args: {}, + projectId, + })) as Array<{ id: string; name: string }>; + } + if (!state.linearApiKey) { + throw new Error('Enter your API key before fetching teams'); + } + return (await trpcClient.pm.discovery.discover.mutate({ + providerId: 'linear', + capability: 'teams', + args: {}, + credentials: { api_key: state.linearApiKey }, + })) as Array<{ id: string; name: string }>; + }, + onSuccess: (teams) => + dispatch({ + type: 'SET_LINEAR_TEAMS', + teams: teams as LinearTeamOption[], + }), + }); + + const linearDetailsMutation = useMutation({ + mutationFn: (teamId: string) => { + if (state.isEditing && state.hasStoredCredentials && !state.linearApiKey) { + return trpcClient.integrationsDiscovery.linearTeamDetailsByProject.mutate({ + projectId, + teamId, + }); + } + if (!state.linearApiKey) { + throw new Error('Enter your API key before fetching team details'); + } + return trpcClient.integrationsDiscovery.linearTeamDetails.mutate({ + apiKey: state.linearApiKey, + teamId, + }); + }, + onSuccess: (details) => { + dispatch({ + type: 'SET_LINEAR_TEAM_DETAILS', + details: details as LinearTeamDetails, + }); + advanceToStep(4); + }, + }); + + const linearProjectsMutation = useMutation({ + mutationFn: async (teamId: string) => { + // Plan 010/2: routes through generic pm.discovery.discover. + if (state.isEditing && state.hasStoredCredentials && !state.linearApiKey) { + return (await trpcClient.pm.discovery.discover.mutate({ + providerId: 'linear', + capability: 'projects', + args: { containerId: teamId }, + projectId, + })) as Array<{ id: string; name: string }>; + } + if (!state.linearApiKey) { + throw new Error('Enter your API key before fetching projects'); + } + return (await trpcClient.pm.discovery.discover.mutate({ + providerId: 'linear', + capability: 'projects', + args: { containerId: teamId }, + credentials: { api_key: state.linearApiKey }, + })) as Array<{ id: string; name: string }>; + }, + onSuccess: (projects) => + dispatch({ + type: 'SET_LINEAR_PROJECTS', + projects: projects as LinearProjectOption[], + }), + }); + + const handleTeamSelect = (teamId: string) => { + dispatch({ type: 'SET_LINEAR_TEAM_ID', id: teamId }); + if (teamId) { + linearDetailsMutation.mutate(teamId); + linearProjectsMutation.mutate(teamId); + } + }; + + // Auto-fetch teams when verification result changes + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger only on verification result change + useEffect(() => { + if (!state.verificationResult || state.provider !== 'linear') return; + if (state.linearTeams.length === 0 && !linearTeamsMutation.isPending) { + linearTeamsMutation.mutate(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.verificationResult]); + + // In edit mode, auto-fetch team list, details, and projects. + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger on edit mode and stored creds + useEffect(() => { + if (!state.isEditing || state.provider !== 'linear') return; + const canFetch = state.linearApiKey ? true : state.hasStoredCredentials; + if (canFetch && state.linearTeams.length === 0 && !linearTeamsMutation.isPending) { + linearTeamsMutation.mutate(); + } + if ( + state.linearTeamId && + !state.linearTeamDetails && + canFetch && + !linearDetailsMutation.isPending + ) { + linearDetailsMutation.mutate(state.linearTeamId); + } + if ( + state.linearTeamId && + state.linearProjects.length === 0 && + canFetch && + !linearProjectsMutation.isPending + ) { + linearProjectsMutation.mutate(state.linearTeamId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.isEditing, state.linearTeamId, state.hasStoredCredentials]); + + return { linearTeamsMutation, linearDetailsMutation, linearProjectsMutation, handleTeamSelect }; +} + +// ============================================================================ +// Linear Label Creation +// ============================================================================ + +export function useLinearLabelCreation( + state: WizardState, + dispatch: Dispatch, + projectId: string, +) { + return useProviderLabelCreation( + { + providerId: 'linear', + getContainerId: (s) => s.linearTeamId, + containerError: 'Team must be selected before creating a label', + addLabel: (label) => ({ type: 'ADD_LINEAR_TEAM_LABEL', label }), + setLabelMapping: (slot, id) => ({ type: 'SET_LINEAR_LABEL', key: slot, value: id }), + }, + state, + dispatch, + projectId, + ); +} diff --git a/web/src/components/projects/pm-providers/linear/wizard.ts b/web/src/components/projects/pm-providers/linear/wizard.ts index b50d045b..5ba51a41 100644 --- a/web/src/components/projects/pm-providers/linear/wizard.ts +++ b/web/src/components/projects/pm-providers/linear/wizard.ts @@ -23,7 +23,6 @@ import { useQuery } from '@tanstack/react-query'; import { type ReactElement, useState } from 'react'; import { API_URL } from '@/lib/api.js'; import { trpc } from '@/lib/trpc.js'; -import { useLinearDiscovery, useLinearLabelCreation } from '../../pm-wizard-hooks.js'; import { buildLinearIntegrationConfig } from '../../pm-wizard-state.js'; import type { ProjectCredentialMeta } from '../../project-secret-field.js'; import { ContainerPickStep } from '../steps/container-pick.js'; @@ -32,6 +31,7 @@ import { LabelMappingStep } from '../steps/label-mapping.js'; import { ProjectScopeStep } from '../steps/project-scope.js'; import { StatusMappingStep } from '../steps/status-mapping.js'; import type { ProviderWizardDefinition, ProviderWizardStepProps } from '../types.js'; +import { useLinearDiscovery, useLinearLabelCreation } from './hooks.js'; import { LinearWebhookAdapter } from './webhook-step.js'; // CASCADE stage keys that map to Linear workflow state IDs (UUIDs — diff --git a/web/src/components/projects/pm-providers/trello/hooks.ts b/web/src/components/projects/pm-providers/trello/hooks.ts new file mode 100644 index 00000000..803bf333 --- /dev/null +++ b/web/src/components/projects/pm-providers/trello/hooks.ts @@ -0,0 +1,165 @@ +import { useMutation } from '@tanstack/react-query'; +import type { Dispatch } from 'react'; +import { useEffect } from 'react'; +import { trpcClient } from '@/lib/trpc.js'; +import { useProviderCustomFieldCreation, useProviderLabelCreation } from '../../pm-wizard-hooks.js'; +import type { WizardAction, WizardState } from '../../pm-wizard-state.js'; + +// ============================================================================ +// Trello Discovery +// ============================================================================ + +export function useTrelloDiscovery( + state: WizardState, + dispatch: Dispatch, + advanceToStep: (step: number) => void, + projectId: string, +) { + const boardsMutation = useMutation({ + mutationFn: async () => { + // Plan 010/2: routes through generic pm.discovery.discover. + // In edit mode with stored credentials, pass projectId; otherwise + // pass raw credentials from wizard state. + if (state.isEditing && state.hasStoredCredentials && !state.trelloApiKey) { + return (await trpcClient.pm.discovery.discover.mutate({ + providerId: 'trello', + capability: 'boards', + args: {}, + projectId, + })) as Array<{ id: string; name: string; url?: string }>; + } + if (!state.trelloApiKey || !state.trelloToken) { + throw new Error('Enter both credentials before fetching boards'); + } + return (await trpcClient.pm.discovery.discover.mutate({ + providerId: 'trello', + capability: 'boards', + args: {}, + credentials: { api_key: state.trelloApiKey, token: state.trelloToken }, + })) as Array<{ id: string; name: string; url?: string }>; + }, + onSuccess: (boards) => + dispatch({ + type: 'SET_TRELLO_BOARDS', + boards: boards.map((b) => ({ ...b, url: b.url ?? '' })), + }), + }); + + const boardDetailsMutation = useMutation({ + mutationFn: (boardId: string) => { + if (state.isEditing && state.hasStoredCredentials && !state.trelloApiKey) { + return trpcClient.integrationsDiscovery.trelloBoardDetailsByProject.mutate({ + projectId, + boardId, + }); + } + if (!state.trelloApiKey || !state.trelloToken) { + throw new Error('Enter both credentials before fetching board details'); + } + return trpcClient.integrationsDiscovery.trelloBoardDetails.mutate({ + apiKey: state.trelloApiKey, + token: state.trelloToken, + boardId, + }); + }, + onSuccess: (details) => { + dispatch({ type: 'SET_TRELLO_BOARD_DETAILS', details }); + advanceToStep(4); + }, + }); + + const handleBoardSelect = (boardId: string) => { + dispatch({ type: 'SET_TRELLO_BOARD_ID', id: boardId }); + if (boardId) { + boardDetailsMutation.mutate(boardId); + } + }; + + // Auto-fetch boards when verification result changes + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger only on verification result change + useEffect(() => { + if (!state.verificationResult || state.provider !== 'trello') return; + if (state.trelloBoards.length === 0 && !boardsMutation.isPending) { + boardsMutation.mutate(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.verificationResult]); + + // In edit mode, auto-fetch board list and details + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger on edit mode and stored creds + useEffect(() => { + if (!state.isEditing || state.provider !== 'trello') return; + const canFetch = state.trelloApiKey ? !!state.trelloToken : state.hasStoredCredentials; + if (canFetch && state.trelloBoards.length === 0 && !boardsMutation.isPending) { + boardsMutation.mutate(); + } + if ( + state.trelloBoardId && + !state.trelloBoardDetails && + canFetch && + !boardDetailsMutation.isPending + ) { + boardDetailsMutation.mutate(state.trelloBoardId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.isEditing, state.trelloBoardId, state.hasStoredCredentials]); + + return { boardsMutation, boardDetailsMutation, handleBoardSelect }; +} + +// ============================================================================ +// Trello Label Creation +// ============================================================================ + +export function useTrelloLabelCreation( + state: WizardState, + dispatch: Dispatch, + projectId: string, +) { + return useProviderLabelCreation( + { + providerId: 'trello', + getContainerId: (s) => s.trelloBoardId, + containerError: 'Board must be selected before creating a label', + addLabel: (label) => ({ type: 'ADD_TRELLO_BOARD_LABEL', label }), + setLabelMapping: (slot, id) => ({ type: 'SET_TRELLO_LABEL_MAPPING', key: slot, value: id }), + }, + state, + dispatch, + projectId, + ); +} + +// ============================================================================ +// Trello Custom Field Creation +// ============================================================================ + +export function useTrelloCustomFieldCreation( + state: WizardState, + dispatch: Dispatch, + projectId: string, +) { + return useProviderCustomFieldCreation( + { + providerId: 'trello', + getContainerId: (s) => s.trelloBoardId, + containerError: 'Board must be selected before creating a custom field', + addCustomField: (f) => ({ type: 'ADD_TRELLO_BOARD_CUSTOM_FIELD', customField: f }), + setCostField: (id) => ({ type: 'SET_TRELLO_COST_FIELD', id }), + onError: (error) => { + console.error('Failed to create custom field:', error); + const message = error instanceof Error ? error.message : String(error); + if (message.includes('403')) { + alert( + 'Failed to create custom field: The Trello Custom Fields power-up is required. Please enable it on your Trello board and try again.', + ); + } else { + alert(`Failed to create custom field: ${message}`); + } + }, + }, + state, + dispatch, + projectId, + ); +} diff --git a/web/src/components/projects/pm-providers/trello/wizard.ts b/web/src/components/projects/pm-providers/trello/wizard.ts index 97482d14..51919e52 100644 --- a/web/src/components/projects/pm-providers/trello/wizard.ts +++ b/web/src/components/projects/pm-providers/trello/wizard.ts @@ -21,17 +21,17 @@ import type { ReactElement } from 'react'; import { useState } from 'react'; import { API_URL } from '@/lib/api.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; -import { - useTrelloCustomFieldCreation, - useTrelloDiscovery, - useTrelloLabelCreation, -} from '../../pm-wizard-hooks.js'; import { deriveActiveWebhooks } from '../../pm-wizard-state.js'; import { ContainerPickStep } from '../steps/container-pick.js'; import { CustomFieldMappingStep } from '../steps/custom-field-mapping.js'; import { LabelMappingStep } from '../steps/label-mapping.js'; import { StatusMappingStep } from '../steps/status-mapping.js'; import type { ProviderWizardDefinition, ProviderWizardStepProps } from '../types.js'; +import { + useTrelloCustomFieldCreation, + useTrelloDiscovery, + useTrelloLabelCreation, +} from './hooks.js'; import { TrelloOAuthStep } from './oauth-step.js'; import { TrelloWebhookAdapter } from './webhook-step.js'; diff --git a/web/src/components/projects/pm-wizard-hooks.ts b/web/src/components/projects/pm-wizard-hooks.ts index d1487233..d73fd661 100644 --- a/web/src/components/projects/pm-wizard-hooks.ts +++ b/web/src/components/projects/pm-wizard-hooks.ts @@ -7,23 +7,14 @@ * - useProviderLabelCreation— parameterized label-creation hook (replaces 2 copies) * - useProviderCustomFieldCreation — parameterized CF hook (replaces 2 copies) * - useSaveMutation — data-driven, no provider branching - * - * Per-provider thin wrappers (useTrelloDiscovery, etc.) remain exported for - * backward-compatibility with existing wizard.ts imports. */ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useEffect } from 'react'; +import type { Dispatch } from 'react'; import { trpc, trpcClient } from '@/lib/trpc.js'; import { getCredentialRoles } from '../../../../src/config/integrationRoles.js'; import type { ProviderAuthMetadata, ProviderWizardDefinition } from './pm-providers/types.js'; -import type { - LinearProjectOption, - LinearTeamDetails, - LinearTeamOption, - WizardAction, - WizardState, -} from './pm-wizard-state.js'; +import type { WizardAction, WizardState } from './pm-wizard-state.js'; import { shouldUseStoredCredentials } from './pm-wizard-state.js'; // ============================================================================ @@ -151,7 +142,7 @@ interface LabelCreationConfig { function useProviderLabelCreation( config: LabelCreationConfig, state: WizardState, - dispatch: React.Dispatch, + dispatch: Dispatch, projectId: string, ) { const createLabelMutation = useMutation({ @@ -234,7 +225,7 @@ interface CustomFieldCreationConfig { function useProviderCustomFieldCreation( config: CustomFieldCreationConfig, state: WizardState, - dispatch: React.Dispatch, + dispatch: Dispatch, projectId: string, ) { const createCustomFieldMutation = useMutation({ @@ -273,353 +264,6 @@ function useProviderCustomFieldCreation( return { createCustomFieldMutation }; } -// ============================================================================ -// Trello Discovery -// ============================================================================ - -export function useTrelloDiscovery( - state: WizardState, - dispatch: React.Dispatch, - advanceToStep: (step: number) => void, - projectId: string, -) { - const boardsMutation = useMutation({ - mutationFn: async () => { - // Plan 010/2: routes through generic pm.discovery.discover. - // In edit mode with stored credentials, pass projectId — the - // endpoint resolves credentials from project_credentials. - // Otherwise pass raw credentials from wizard state. - if (state.isEditing && state.hasStoredCredentials && !state.trelloApiKey) { - return (await trpcClient.pm.discovery.discover.mutate({ - providerId: 'trello', - capability: 'boards', - args: {}, - projectId, - })) as Array<{ id: string; name: string; url?: string }>; - } - if (!state.trelloApiKey || !state.trelloToken) { - throw new Error('Enter both credentials before fetching boards'); - } - return (await trpcClient.pm.discovery.discover.mutate({ - providerId: 'trello', - capability: 'boards', - args: {}, - credentials: { api_key: state.trelloApiKey, token: state.trelloToken }, - })) as Array<{ id: string; name: string; url?: string }>; - }, - onSuccess: (boards) => - dispatch({ - type: 'SET_TRELLO_BOARDS', - boards: boards.map((b) => ({ ...b, url: b.url ?? '' })), - }), - }); - - const boardDetailsMutation = useMutation({ - mutationFn: (boardId: string) => { - if (state.isEditing && state.hasStoredCredentials && !state.trelloApiKey) { - return trpcClient.integrationsDiscovery.trelloBoardDetailsByProject.mutate({ - projectId, - boardId, - }); - } - if (!state.trelloApiKey || !state.trelloToken) { - throw new Error('Enter both credentials before fetching board details'); - } - return trpcClient.integrationsDiscovery.trelloBoardDetails.mutate({ - apiKey: state.trelloApiKey, - token: state.trelloToken, - boardId, - }); - }, - onSuccess: (details) => { - dispatch({ type: 'SET_TRELLO_BOARD_DETAILS', details }); - advanceToStep(4); - }, - }); - - const handleBoardSelect = (boardId: string) => { - dispatch({ type: 'SET_TRELLO_BOARD_ID', id: boardId }); - if (boardId) { - boardDetailsMutation.mutate(boardId); - } - }; - - // Auto-fetch boards when verification result changes - // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger only on verification result change - useEffect(() => { - if (!state.verificationResult || state.provider !== 'trello') return; - if (state.trelloBoards.length === 0 && !boardsMutation.isPending) { - boardsMutation.mutate(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.verificationResult]); - - // In edit mode, auto-fetch board list and details - // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger on edit mode and stored creds - useEffect(() => { - if (!state.isEditing || state.provider !== 'trello') return; - const canFetch = state.trelloApiKey ? !!state.trelloToken : state.hasStoredCredentials; - if (canFetch && state.trelloBoards.length === 0 && !boardsMutation.isPending) { - boardsMutation.mutate(); - } - if ( - state.trelloBoardId && - !state.trelloBoardDetails && - canFetch && - !boardDetailsMutation.isPending - ) { - boardDetailsMutation.mutate(state.trelloBoardId); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.isEditing, state.trelloBoardId, state.hasStoredCredentials]); - - return { boardsMutation, boardDetailsMutation, handleBoardSelect }; -} - -// ============================================================================ -// JIRA Discovery -// ============================================================================ - -export function useJiraDiscovery( - state: WizardState, - dispatch: React.Dispatch, - advanceToStep: (step: number) => void, - projectId: string, -) { - const jiraProjectsMutation = useMutation({ - mutationFn: async () => { - // Plan 010/2: routes through generic pm.discovery.discover. - if (state.isEditing && state.hasStoredCredentials && !state.jiraEmail) { - const projects = (await trpcClient.pm.discovery.discover.mutate({ - providerId: 'jira', - capability: 'projects', - args: {}, - projectId, - })) as Array<{ id: string; name: string }>; - // Legacy shape has `key` — pm.discover returns `id` containing - // the JIRA key. Normalize for downstream consumers. - return projects.map((p) => ({ key: p.id, name: p.name })); - } - if (!state.jiraEmail || !state.jiraApiToken) { - throw new Error('Enter both credentials before fetching projects'); - } - const projects = (await trpcClient.pm.discovery.discover.mutate({ - providerId: 'jira', - capability: 'projects', - args: {}, - credentials: { - email: state.jiraEmail, - api_token: state.jiraApiToken, - base_url: state.jiraBaseUrl, - }, - })) as Array<{ id: string; name: string }>; - return projects.map((p) => ({ key: p.id, name: p.name })); - }, - onSuccess: (projects) => dispatch({ type: 'SET_JIRA_PROJECTS', projects }), - }); - - const jiraDetailsMutation = useMutation({ - mutationFn: (projectKey: string) => { - if (state.isEditing && state.hasStoredCredentials && !state.jiraEmail) { - return trpcClient.integrationsDiscovery.jiraProjectDetailsByProject.mutate({ - projectId, - projectKey, - }); - } - if (!state.jiraEmail || !state.jiraApiToken) { - throw new Error('Enter both credentials before fetching project details'); - } - return trpcClient.integrationsDiscovery.jiraProjectDetails.mutate({ - email: state.jiraEmail, - apiToken: state.jiraApiToken, - baseUrl: state.jiraBaseUrl, - projectKey, - }); - }, - onSuccess: (details) => { - dispatch({ type: 'SET_JIRA_PROJECT_DETAILS', details }); - advanceToStep(4); - }, - }); - - const handleProjectSelect = (key: string) => { - dispatch({ type: 'SET_JIRA_PROJECT_KEY', key }); - if (key) { - jiraDetailsMutation.mutate(key); - } - }; - - // Auto-fetch projects when verification result changes - // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger only on verification result change - useEffect(() => { - if (!state.verificationResult || state.provider !== 'jira') return; - if (state.jiraProjects.length === 0 && !jiraProjectsMutation.isPending) { - jiraProjectsMutation.mutate(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.verificationResult]); - - // In edit mode, auto-fetch project list and details - // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger on edit mode and stored creds - useEffect(() => { - if (!state.isEditing || state.provider !== 'jira') return; - const canFetch = state.jiraEmail ? !!state.jiraApiToken : state.hasStoredCredentials; - if (canFetch && state.jiraProjects.length === 0 && !jiraProjectsMutation.isPending) { - jiraProjectsMutation.mutate(); - } - if ( - state.jiraProjectKey && - !state.jiraProjectDetails && - canFetch && - !jiraDetailsMutation.isPending - ) { - jiraDetailsMutation.mutate(state.jiraProjectKey); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.isEditing, state.jiraProjectKey, state.hasStoredCredentials]); - - return { jiraProjectsMutation, jiraDetailsMutation, handleProjectSelect }; -} - -// ============================================================================ -// Linear Discovery -// ============================================================================ - -export function useLinearDiscovery( - state: WizardState, - dispatch: React.Dispatch, - advanceToStep: (step: number) => void, - projectId: string, -) { - const linearTeamsMutation = useMutation({ - mutationFn: async () => { - // Plan 010/2: routes through generic pm.discovery.discover. - if (state.isEditing && state.hasStoredCredentials && !state.linearApiKey) { - return (await trpcClient.pm.discovery.discover.mutate({ - providerId: 'linear', - capability: 'teams', - args: {}, - projectId, - })) as Array<{ id: string; name: string }>; - } - if (!state.linearApiKey) { - throw new Error('Enter your API key before fetching teams'); - } - return (await trpcClient.pm.discovery.discover.mutate({ - providerId: 'linear', - capability: 'teams', - args: {}, - credentials: { api_key: state.linearApiKey }, - })) as Array<{ id: string; name: string }>; - }, - onSuccess: (teams) => - dispatch({ - type: 'SET_LINEAR_TEAMS', - teams: teams as LinearTeamOption[], - }), - }); - - const linearDetailsMutation = useMutation({ - mutationFn: (teamId: string) => { - if (state.isEditing && state.hasStoredCredentials && !state.linearApiKey) { - return trpcClient.integrationsDiscovery.linearTeamDetailsByProject.mutate({ - projectId, - teamId, - }); - } - if (!state.linearApiKey) { - throw new Error('Enter your API key before fetching team details'); - } - return trpcClient.integrationsDiscovery.linearTeamDetails.mutate({ - apiKey: state.linearApiKey, - teamId, - }); - }, - onSuccess: (details) => { - dispatch({ - type: 'SET_LINEAR_TEAM_DETAILS', - details: details as LinearTeamDetails, - }); - advanceToStep(4); - }, - }); - - const linearProjectsMutation = useMutation({ - mutationFn: async (teamId: string) => { - // Plan 010/2: routes through generic pm.discovery.discover. - if (state.isEditing && state.hasStoredCredentials && !state.linearApiKey) { - return (await trpcClient.pm.discovery.discover.mutate({ - providerId: 'linear', - capability: 'projects', - args: { containerId: teamId }, - projectId, - })) as Array<{ id: string; name: string }>; - } - if (!state.linearApiKey) { - throw new Error('Enter your API key before fetching projects'); - } - return (await trpcClient.pm.discovery.discover.mutate({ - providerId: 'linear', - capability: 'projects', - args: { containerId: teamId }, - credentials: { api_key: state.linearApiKey }, - })) as Array<{ id: string; name: string }>; - }, - onSuccess: (projects) => - dispatch({ - type: 'SET_LINEAR_PROJECTS', - projects: projects as LinearProjectOption[], - }), - }); - - const handleTeamSelect = (teamId: string) => { - dispatch({ type: 'SET_LINEAR_TEAM_ID', id: teamId }); - if (teamId) { - linearDetailsMutation.mutate(teamId); - linearProjectsMutation.mutate(teamId); - } - }; - - // Auto-fetch teams when verification result changes - // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger only on verification result change - useEffect(() => { - if (!state.verificationResult || state.provider !== 'linear') return; - if (state.linearTeams.length === 0 && !linearTeamsMutation.isPending) { - linearTeamsMutation.mutate(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.verificationResult]); - - // In edit mode, auto-fetch team list and details - // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger on edit mode and stored creds - useEffect(() => { - if (!state.isEditing || state.provider !== 'linear') return; - const canFetch = state.linearApiKey ? true : state.hasStoredCredentials; - if (canFetch && state.linearTeams.length === 0 && !linearTeamsMutation.isPending) { - linearTeamsMutation.mutate(); - } - if ( - state.linearTeamId && - !state.linearTeamDetails && - canFetch && - !linearDetailsMutation.isPending - ) { - linearDetailsMutation.mutate(state.linearTeamId); - } - if ( - state.linearTeamId && - state.linearProjects.length === 0 && - canFetch && - !linearProjectsMutation.isPending - ) { - linearProjectsMutation.mutate(state.linearTeamId); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.isEditing, state.linearTeamId, state.hasStoredCredentials]); - - return { linearTeamsMutation, linearDetailsMutation, linearProjectsMutation, handleTeamSelect }; -} - // ============================================================================ // Verification // ============================================================================ @@ -660,7 +304,7 @@ export function formatVerificationDisplay( export function useVerification( state: WizardState, - dispatch: React.Dispatch, + dispatch: Dispatch, advanceToStep: (step: number) => void, projectId: string, manifestDef: ProviderWizardDefinition, @@ -708,102 +352,6 @@ export function useVerification( // (`webhooks.list/create/delete` + `callbackBaseUrl` formula) — // see `./pm-providers/{trello,jira,linear}/wizard.ts`. -// ============================================================================ -// Trello Label Creation -// ============================================================================ - -export function useTrelloLabelCreation( - state: WizardState, - dispatch: React.Dispatch, - projectId: string, -) { - return useProviderLabelCreation( - { - providerId: 'trello', - getContainerId: (s) => s.trelloBoardId, - containerError: 'Board must be selected before creating a label', - addLabel: (label) => ({ type: 'ADD_TRELLO_BOARD_LABEL', label }), - setLabelMapping: (slot, id) => ({ type: 'SET_TRELLO_LABEL_MAPPING', key: slot, value: id }), - }, - state, - dispatch, - projectId, - ); -} - -// ============================================================================ -// Trello Custom Field Creation -// ============================================================================ - -export function useTrelloCustomFieldCreation( - state: WizardState, - dispatch: React.Dispatch, - projectId: string, -) { - return useProviderCustomFieldCreation( - { - providerId: 'trello', - getContainerId: (s) => s.trelloBoardId, - containerError: 'Board must be selected before creating a custom field', - addCustomField: (f) => ({ type: 'ADD_TRELLO_BOARD_CUSTOM_FIELD', customField: f }), - setCostField: (id) => ({ type: 'SET_TRELLO_COST_FIELD', id }), - onError: (error) => { - console.error('Failed to create custom field:', error); - const message = error instanceof Error ? error.message : String(error); - if (message.includes('403')) { - alert( - 'Failed to create custom field: The Trello Custom Fields power-up is required. Please enable it on your Trello board and try again.', - ); - } else { - alert(`Failed to create custom field: ${message}`); - } - }, - }, - state, - dispatch, - projectId, - ); -} - -// ============================================================================ -// JIRA Custom Field Creation -// ============================================================================ - -export function useJiraCustomFieldCreation( - state: WizardState, - dispatch: React.Dispatch, - projectId: string, -) { - const inner = useProviderCustomFieldCreation( - { - providerId: 'jira', - // JIRA fields are global; containerId is sent as-is for uniform shape - getContainerId: (s) => s.jiraProjectKey || 'global', - addCustomField: (f) => ({ - type: 'ADD_JIRA_PROJECT_CUSTOM_FIELD', - field: { ...f, custom: true }, - }), - setCostField: (id) => ({ type: 'SET_JIRA_COST_FIELD', id }), - onError: (error) => { - console.error('Failed to create JIRA custom field:', error); - const message = error instanceof Error ? error.message : String(error); - if (message.includes('403') || message.toLowerCase().includes('admin')) { - alert( - 'Failed to create custom field: JIRA admin permissions are required to create global custom fields. Please contact your JIRA administrator.', - ); - } else { - alert(`Failed to create JIRA custom field: ${message}`); - } - }, - }, - state, - dispatch, - projectId, - ); - // Preserve the legacy export name for JIRA callers - return { createJiraCustomFieldMutation: inner.createCustomFieldMutation }; -} - // ============================================================================ // Save Mutation — data-driven, no per-provider branching // ============================================================================ @@ -895,29 +443,6 @@ export function useSaveMutation( return { saveMutation }; } -// ============================================================================ -// Linear Label Creation -// ============================================================================ - -export function useLinearLabelCreation( - state: WizardState, - dispatch: React.Dispatch, - projectId: string, -) { - return useProviderLabelCreation( - { - providerId: 'linear', - getContainerId: (s) => s.linearTeamId, - containerError: 'Team must be selected before creating a label', - addLabel: (label) => ({ type: 'ADD_LINEAR_TEAM_LABEL', label }), - setLabelMapping: (slot, id) => ({ type: 'SET_LINEAR_LABEL', key: slot, value: id }), - }, - state, - dispatch, - projectId, - ); -} - export type { CustomFieldCreationConfig, LabelCreationConfig }; // Re-export the generic utilities for direct use in tests / advanced consumers export { useProviderCustomFieldCreation, useProviderLabelCreation };