From 0241783c7cfd506baf59e4605901ccb8ddf5cb15 Mon Sep 17 00:00:00 2001 From: aaight Date: Sun, 10 May 2026 09:38:31 +0200 Subject: [PATCH 1/3] feat(pm-wizard): add provider-owned credential metadata (#1303) Co-authored-by: Cascade Bot --- tests/unit/web/pm-provider-registry.test.ts | 8 ++ .../web/pm-wizard-generic-renderer.test.ts | 8 ++ tests/unit/web/pm-wizard-hooks.test.ts | 133 ++++++++++++++++++ .../projects/pm-providers/jira/wizard.ts | 13 ++ .../projects/pm-providers/linear/wizard.ts | 8 ++ .../projects/pm-providers/trello/wizard.ts | 12 ++ .../components/projects/pm-providers/types.ts | 39 +++++ .../components/projects/pm-wizard-hooks.ts | 25 ++++ 8 files changed, 246 insertions(+) diff --git a/tests/unit/web/pm-provider-registry.test.ts b/tests/unit/web/pm-provider-registry.test.ts index 665a5bcb8..a6e9267a8 100644 --- a/tests/unit/web/pm-provider-registry.test.ts +++ b/tests/unit/web/pm-provider-registry.test.ts @@ -12,6 +12,14 @@ function makeStubWizard(id: string): ProviderWizardDefinition { id, label: id, steps: [], + auth: { + rawCredentials: [{ role: 'api_key', stateField: 'linearApiKey' }], + storedCredentials: { fallbackWhenStateFieldEmpty: 'linearApiKey' }, + missingCredentialsMessage: 'Missing credentials', + }, + credentialPersistence: [ + { envVarKey: 'STUB_API_KEY', stateField: 'linearApiKey', label: 'Stub API Key' }, + ], buildIntegrationConfig: () => ({}), isSetupComplete: () => true, }; diff --git a/tests/unit/web/pm-wizard-generic-renderer.test.ts b/tests/unit/web/pm-wizard-generic-renderer.test.ts index 442178f5c..730638d0b 100644 --- a/tests/unit/web/pm-wizard-generic-renderer.test.ts +++ b/tests/unit/web/pm-wizard-generic-renderer.test.ts @@ -32,6 +32,14 @@ function makeStubWizard(id: string): ProviderWizardDefinition { { id: 'container', title: 'Container', Component: StubStep, isComplete: () => true }, { id: 'fields', title: 'Field mappings', Component: StubStep, isComplete: () => true }, ], + auth: { + rawCredentials: [{ role: 'api_key', stateField: 'linearApiKey' }], + storedCredentials: { fallbackWhenStateFieldEmpty: 'linearApiKey' }, + missingCredentialsMessage: 'Missing credentials', + }, + credentialPersistence: [ + { envVarKey: 'STUB_API_KEY', stateField: 'linearApiKey', label: 'Stub API Key' }, + ], buildIntegrationConfig: () => ({}), isSetupComplete: () => true, }; diff --git a/tests/unit/web/pm-wizard-hooks.test.ts b/tests/unit/web/pm-wizard-hooks.test.ts index 81f1927c3..f0d45a704 100644 --- a/tests/unit/web/pm-wizard-hooks.test.ts +++ b/tests/unit/web/pm-wizard-hooks.test.ts @@ -6,8 +6,12 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { jiraProviderWizard } from '../../../web/src/components/projects/pm-providers/jira/wizard.js'; +import { linearProviderWizard } from '../../../web/src/components/projects/pm-providers/linear/wizard.js'; +import { trelloProviderWizard } from '../../../web/src/components/projects/pm-providers/trello/wizard.js'; import { buildProviderAuthArg, + buildProviderAuthArgFromMetadata, runPerLabelCreations, } from '../../../web/src/components/projects/pm-wizard-hooks.js'; import type { WizardState } from '../../../web/src/components/projects/pm-wizard-state.js'; @@ -154,6 +158,135 @@ describe('buildProviderAuthArg', () => { }); }); +// ============================================================================ +// Provider-owned credential metadata +// ============================================================================ + +describe('provider credential metadata', () => { + function trelloState(overrides: Partial = {}): WizardState { + return { ...createInitialState(), provider: 'trello', ...overrides }; + } + function jiraState(overrides: Partial = {}): WizardState { + return { ...createInitialState(), provider: 'jira', ...overrides }; + } + function linearState(overrides: Partial = {}): WizardState { + return { ...createInitialState(), provider: 'linear', ...overrides }; + } + + it('builds stored credential fallback auth payloads from provider metadata', () => { + expect( + buildProviderAuthArgFromMetadata( + trelloState({ isEditing: true, hasStoredCredentials: true, trelloApiKey: '' }), + 'proj-t', + trelloProviderWizard.auth, + ), + ).toEqual({ projectId: 'proj-t' }); + expect( + buildProviderAuthArgFromMetadata( + jiraState({ isEditing: true, hasStoredCredentials: true, jiraEmail: '' }), + 'proj-j', + jiraProviderWizard.auth, + ), + ).toEqual({ projectId: 'proj-j' }); + expect( + buildProviderAuthArgFromMetadata( + linearState({ isEditing: true, hasStoredCredentials: true, linearApiKey: '' }), + 'proj-l', + linearProviderWizard.auth, + ), + ).toEqual({ projectId: 'proj-l' }); + }); + + it('builds raw credential auth payloads from provider metadata', () => { + expect( + buildProviderAuthArgFromMetadata( + trelloState({ trelloApiKey: 'key-abc', trelloToken: 'tok-xyz' }), + 'proj-t', + trelloProviderWizard.auth, + ), + ).toEqual({ credentials: { api_key: 'key-abc', token: 'tok-xyz' } }); + expect( + buildProviderAuthArgFromMetadata( + jiraState({ + jiraEmail: 'user@example.com', + jiraApiToken: 'jira-tok', + jiraBaseUrl: 'https://example.atlassian.net', + }), + 'proj-j', + jiraProviderWizard.auth, + ), + ).toEqual({ + credentials: { + email: 'user@example.com', + api_token: 'jira-tok', + base_url: 'https://example.atlassian.net', + }, + }); + expect( + buildProviderAuthArgFromMetadata( + linearState({ linearApiKey: 'lin_abc' }), + 'proj-l', + linearProviderWizard.auth, + ), + ).toEqual({ credentials: { api_key: 'lin_abc' } }); + }); + + it('throws provider metadata errors when raw credentials are missing', () => { + expect(() => + buildProviderAuthArgFromMetadata( + trelloState({ trelloApiKey: 'key', trelloToken: '' }), + 'proj-t', + trelloProviderWizard.auth, + ), + ).toThrow('Enter both credentials before verifying'); + expect(() => + buildProviderAuthArgFromMetadata( + jiraState({ jiraEmail: 'user@example.com', jiraApiToken: 'tok', jiraBaseUrl: '' }), + 'proj-j', + jiraProviderWizard.auth, + ), + ).toThrow('Enter both credentials before verifying'); + expect(() => + buildProviderAuthArgFromMetadata( + linearState({ linearApiKey: '' }), + 'proj-l', + linearProviderWizard.auth, + ), + ).toThrow('Enter your API key before verifying'); + }); + + it('declares complete normal credential persistence metadata for each provider', () => { + expect(trelloProviderWizard.credentialPersistence).toEqual([ + { envVarKey: 'TRELLO_API_KEY', stateField: 'trelloApiKey', label: 'Trello API Key' }, + { envVarKey: 'TRELLO_TOKEN', stateField: 'trelloToken', label: 'Trello Token' }, + ]); + expect(jiraProviderWizard.credentialPersistence).toEqual([ + { envVarKey: 'JIRA_EMAIL', stateField: 'jiraEmail', label: 'JIRA Email' }, + { envVarKey: 'JIRA_API_TOKEN', stateField: 'jiraApiToken', label: 'JIRA API Token' }, + ]); + expect(linearProviderWizard.credentialPersistence).toEqual([ + { envVarKey: 'LINEAR_API_KEY', stateField: 'linearApiKey', label: 'Linear API Key' }, + ]); + }); + + it('keeps config and webhook secrets out of normal credential persistence metadata', () => { + expect(jiraProviderWizard.auth.rawCredentials.map((c) => c.role)).toEqual([ + 'email', + 'api_token', + 'base_url', + ]); + expect(jiraProviderWizard.credentialPersistence.map((c) => c.envVarKey)).not.toContain( + 'JIRA_BASE_URL', + ); + expect(jiraProviderWizard.credentialPersistence.map((c) => c.envVarKey)).not.toContain( + 'JIRA_WEBHOOK_SECRET', + ); + expect(linearProviderWizard.credentialPersistence.map((c) => c.envVarKey)).not.toContain( + 'LINEAR_WEBHOOK_SECRET', + ); + }); +}); + // ============================================================================ // buildTrelloIntegrationConfig // ============================================================================ diff --git a/web/src/components/projects/pm-providers/jira/wizard.ts b/web/src/components/projects/pm-providers/jira/wizard.ts index 085e43656..7a278cff3 100644 --- a/web/src/components/projects/pm-providers/jira/wizard.ts +++ b/web/src/components/projects/pm-providers/jira/wizard.ts @@ -248,6 +248,19 @@ function JiraIssueTypeAdapter({ export const jiraProviderWizard: ProviderWizardDefinition = { id: 'jira', label: 'JIRA', + auth: { + rawCredentials: [ + { role: 'email', stateField: 'jiraEmail' }, + { role: 'api_token', stateField: 'jiraApiToken' }, + { role: 'base_url', stateField: 'jiraBaseUrl' }, + ], + storedCredentials: { fallbackWhenStateFieldEmpty: 'jiraEmail' }, + missingCredentialsMessage: 'Enter both credentials before verifying', + }, + credentialPersistence: [ + { envVarKey: 'JIRA_EMAIL', stateField: 'jiraEmail', label: 'JIRA Email' }, + { envVarKey: 'JIRA_API_TOKEN', stateField: 'jiraApiToken', label: 'JIRA API Token' }, + ], steps: [ { diff --git a/web/src/components/projects/pm-providers/linear/wizard.ts b/web/src/components/projects/pm-providers/linear/wizard.ts index 06a8f6840..b50d045b8 100644 --- a/web/src/components/projects/pm-providers/linear/wizard.ts +++ b/web/src/components/projects/pm-providers/linear/wizard.ts @@ -231,6 +231,14 @@ function LinearProjectScopeAdapter({ providerHooks }: ProviderWizardStepProps): export const linearProviderWizard: ProviderWizardDefinition = { id: 'linear', label: 'Linear', + auth: { + rawCredentials: [{ role: 'api_key', stateField: 'linearApiKey' }], + storedCredentials: { fallbackWhenStateFieldEmpty: 'linearApiKey' }, + missingCredentialsMessage: 'Enter your API key before verifying', + }, + credentialPersistence: [ + { envVarKey: 'LINEAR_API_KEY', stateField: 'linearApiKey', label: 'Linear API Key' }, + ], steps: [ { diff --git a/web/src/components/projects/pm-providers/trello/wizard.ts b/web/src/components/projects/pm-providers/trello/wizard.ts index 013640bbc..97482d14f 100644 --- a/web/src/components/projects/pm-providers/trello/wizard.ts +++ b/web/src/components/projects/pm-providers/trello/wizard.ts @@ -247,6 +247,18 @@ function TrelloCustomFieldMappingAdapter({ export const trelloProviderWizard: ProviderWizardDefinition = { id: 'trello', label: 'Trello', + auth: { + rawCredentials: [ + { role: 'api_key', stateField: 'trelloApiKey' }, + { role: 'token', stateField: 'trelloToken' }, + ], + storedCredentials: { fallbackWhenStateFieldEmpty: 'trelloApiKey' }, + missingCredentialsMessage: 'Enter both credentials before verifying', + }, + credentialPersistence: [ + { envVarKey: 'TRELLO_API_KEY', stateField: 'trelloApiKey', label: 'Trello API Key' }, + { envVarKey: 'TRELLO_TOKEN', stateField: 'trelloToken', label: 'Trello Token' }, + ], // Each step mirrors `trelloManifest.wizardSpec.steps` by id. steps: [ diff --git a/web/src/components/projects/pm-providers/types.ts b/web/src/components/projects/pm-providers/types.ts index 045fcb4d2..506625be3 100644 --- a/web/src/components/projects/pm-providers/types.ts +++ b/web/src/components/projects/pm-providers/types.ts @@ -51,6 +51,41 @@ export interface ProviderHooksContext { readonly advanceToStep: (step: number) => void; } +export interface ProviderAuthCredentialMapping { + /** Provider API credential key sent to discovery endpoints. */ + readonly role: string; + /** Wizard state field containing the raw credential/config value. */ + readonly stateField: keyof WizardState; + /** Overrides the provider-level missing-credential message for this field. */ + readonly missingMessage?: string; +} + +export interface ProviderStoredCredentialAuth { + /** + * In edit mode, an empty raw credential field means "use credentials already + * saved on this project" and sends `{ projectId }` instead of raw secrets. + */ + readonly fallbackWhenStateFieldEmpty: keyof WizardState; +} + +export interface ProviderAuthMetadata { + /** Raw credential payload shape for verification/discovery calls. */ + readonly rawCredentials: readonly ProviderAuthCredentialMapping[]; + /** Stored-project-credential fallback shape for edit mode. */ + readonly storedCredentials: ProviderStoredCredentialAuth; + /** Default error when required raw credentials are missing. */ + readonly missingCredentialsMessage: string; +} + +export interface ProviderCredentialPersistenceMapping { + /** Environment variable key persisted to project_credentials. */ + readonly envVarKey: string; + /** Wizard state field containing the value to persist. */ + readonly stateField: keyof WizardState; + /** Human-readable name stored with the credential. */ + readonly label: string; +} + export interface ProviderWizardDefinition { /** Must match the backend manifest id (e.g. 'trello', 'linear'). */ readonly id: string; @@ -58,6 +93,10 @@ export interface ProviderWizardDefinition { readonly label: string; /** Ordered list of wizard steps. */ readonly steps: readonly ProviderWizardStep[]; + /** Provider-owned auth contract for raw credentials and stored fallback. */ + readonly auth: ProviderAuthMetadata; + /** Normal provider credentials saved to project_credentials. */ + readonly credentialPersistence: readonly ProviderCredentialPersistenceMapping[]; /** * Transforms wizard state into the integration config payload sent to the * save API. Mirrors the existing `buildXxxIntegrationConfig` functions. diff --git a/web/src/components/projects/pm-wizard-hooks.ts b/web/src/components/projects/pm-wizard-hooks.ts index c34f3ef2d..82827e87c 100644 --- a/web/src/components/projects/pm-wizard-hooks.ts +++ b/web/src/components/projects/pm-wizard-hooks.ts @@ -16,6 +16,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useEffect } from 'react'; import { trpc, trpcClient } from '@/lib/trpc.js'; import { getCredentialRoles } from '../../../../src/config/integrationRoles.js'; +import type { ProviderAuthMetadata } from './pm-providers/types.js'; import type { LinearProjectOption, LinearTeamDetails, @@ -75,6 +76,30 @@ export function buildProviderAuthArg( }; } +export function buildProviderAuthArgFromMetadata( + state: WizardState, + projectId: string, + metadata: ProviderAuthMetadata, +): { projectId: string } | { credentials: Record } { + if ( + shouldUseStoredCredentials(state) && + !state[metadata.storedCredentials.fallbackWhenStateFieldEmpty] + ) { + return { projectId }; + } + + const credentials: Record = {}; + for (const field of metadata.rawCredentials) { + const rawValue = state[field.stateField]; + const value = typeof rawValue === 'string' ? rawValue : ''; + if (!value) { + throw new Error(field.missingMessage ?? metadata.missingCredentialsMessage); + } + credentials[field.role] = value; + } + return { credentials }; +} + // ============================================================================ // Label creation utilities // ============================================================================ From 46db061d6c7d1c3ca86f059a84109129c8a426ab Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Sun, 10 May 2026 07:54:48 +0000 Subject: [PATCH 2/3] chore(prompts): rewrite friction guidance to be action-triggered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live signal from prod 2026-05-10 PR #1303 (a4585987 implementation run): the agent encountered a textbook incidental papercut — `CASCADE_ORG_ID=mongrel` leaking from worker env into `tests/unit/cli/dashboard/config.test.ts`, worked around with `env -u CASCADE_ORG_ID npm test` — but reported it in the PR body instead of via `ReportFriction`. The plumbing was correct (capability scoped, gadget surfaced, sidecar env var injected, CLI installed); the prompt just wasn't recruiting the agent. The prior text framed reporting as a constraint, not a trigger: > When the ReportFriction tool is available, use it ONLY for > incidental papercuts in the environment, tooling, repository setup, > documentation, or developer workflow that make the work harder > than it should be. > > Do not report core task difficulty, expected debugging effort, > product ambiguity that belongs in the current work item, or issues > you can resolve directly as part of the assigned task. > > Keep working after reporting friction unless the issue blocks > progress. ... Three problems with this framing — verified by reading other agent prompt sections that DO recruit action well (`## Git`, `### Service Recovery`, `### Test Writing Protocol`): 1. "use it only for ..." reads as a SCOPING CONSTRAINT, not an action trigger. Agents default to "skip if uncertain." 2. Three negative clauses ("Do not report ...") with zero positive trigger language. Bias is toward not reporting. 3. "When the ReportFriction tool is available" hedge — but the guidance is only injected when the capability IS effective, so the conditional just creates agent doubt. New text drops all three. Action-trigger framing, "when in doubt, report" calibration, "better to over-report initial papercuts" license, non-blocking semantic preserved. No example list — would create pattern-match traps that narrow the use; the gadget's `category` enum (tooling | environment | permissions | dependency | test-failure | pm-data | scm-data | other) provides the categorical anchoring at invocation time. Tests updated: - `tests/unit/agents/shared/frictionGuidance.test.ts` — flipped from "expect old text" to assert the action-trigger framing + the intentional removal of the prior under-reporting clauses. - `tests/unit/backends/secretOrchestrator.test.ts:260` — flipped the prompt-assembly test's `toContain('incidental papercuts')` style assertions to the new content. No code wiring changes. The plumbing stays exactly as PR #1296/#1298/#1300 shipped it. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/agents/shared/frictionGuidance.ts | 6 +-- .../agents/shared/frictionGuidance.test.ts | 40 +++++++++++++++++-- .../unit/backends/secretOrchestrator.test.ts | 12 ++++-- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/src/agents/shared/frictionGuidance.ts b/src/agents/shared/frictionGuidance.ts index 355cf5679..24d794cf0 100644 --- a/src/agents/shared/frictionGuidance.ts +++ b/src/agents/shared/frictionGuidance.ts @@ -2,11 +2,9 @@ import type { Capability } from '../capabilities/index.js'; export const FRICTION_REPORTING_GUIDANCE = `## Friction Reporting -When the ReportFriction tool is available, use it only for incidental papercuts in the environment, tooling, repository setup, documentation, or developer workflow that make the work harder than it should be. +When something makes your work harder than it strictly needs to be — at any point during the task — file it with \`ReportFriction\`. When in doubt, report. Better to over-report initial papercuts than let recurring friction go invisible. -Do not report core task difficulty, expected debugging effort, product ambiguity that belongs in the current work item, or issues you can resolve directly as part of the assigned task. - -Keep working after reporting friction unless the issue blocks progress. If blocked, report the friction with concrete context and then explain the blocker in your final response.`; +After filing, keep working; only let friction block your task if it actually blocks it.`; export function shouldAppendFrictionGuidance(capabilities: readonly Capability[]): boolean { return capabilities.includes('pm:friction'); diff --git a/tests/unit/agents/shared/frictionGuidance.test.ts b/tests/unit/agents/shared/frictionGuidance.test.ts index b38103cdf..83f89762d 100644 --- a/tests/unit/agents/shared/frictionGuidance.test.ts +++ b/tests/unit/agents/shared/frictionGuidance.test.ts @@ -18,9 +18,41 @@ describe('friction reporting guidance', () => { ); }); - it('limits reports to incidental papercuts and tells agents to keep working unless blocked', () => { - expect(FRICTION_REPORTING_GUIDANCE).toContain('incidental papercuts'); - expect(FRICTION_REPORTING_GUIDANCE).toContain('Do not report core task difficulty'); - expect(FRICTION_REPORTING_GUIDANCE).toContain('Keep working after reporting friction unless'); + it('uses an action-trigger framing that calibrates toward over-reporting', () => { + // Section heading the agent will see in its system prompt. + expect(FRICTION_REPORTING_GUIDANCE).toContain('## Friction Reporting'); + + // Direct imperative — the rewrite (2026-05-10) replaced the prior + // "use it only for incidental papercuts in the environment, tooling, + // repository setup, documentation, or developer workflow" framing + // because that scoping read as a constraint, not a trigger. The new + // framing is "when X happens, do Y" with explicit "when in doubt, + // report" calibration. + expect(FRICTION_REPORTING_GUIDANCE).toContain( + 'makes your work harder than it strictly needs to be', + ); + expect(FRICTION_REPORTING_GUIDANCE).toContain('When in doubt, report'); + expect(FRICTION_REPORTING_GUIDANCE).toContain('Better to over-report'); + + // Non-blocking semantic preserved — friction stays a sidebar to the + // main task; it doesn't derail. + expect(FRICTION_REPORTING_GUIDANCE).toContain( + 'only let friction block your task if it actually blocks it', + ); + + // Negative scoping intentionally REMOVED (was: "Do not report core + // task difficulty, expected debugging effort, product ambiguity..." + // — that was the source of under-reporting, surfaced live on + // 2026-05-10 PR #1303 where the implementation agent hit a + // CASCADE_ORG_ID env-var leak in tests, worked around it with + // `env -u`, and reported it in the PR body instead of via + // ReportFriction). + expect(FRICTION_REPORTING_GUIDANCE).not.toContain('Do not report'); + expect(FRICTION_REPORTING_GUIDANCE).not.toContain('only for incidental papercuts'); + + // "When the ReportFriction tool is available" hedge REMOVED — the + // guidance is only injected when the capability is effective, so + // the conditional just creates agent doubt. + expect(FRICTION_REPORTING_GUIDANCE).not.toContain('When the ReportFriction tool is available'); }); }); diff --git a/tests/unit/backends/secretOrchestrator.test.ts b/tests/unit/backends/secretOrchestrator.test.ts index 8cc9829fe..10a762515 100644 --- a/tests/unit/backends/secretOrchestrator.test.ts +++ b/tests/unit/backends/secretOrchestrator.test.ts @@ -272,9 +272,15 @@ describe('buildExecutionPlan', () => { engine, ); - expect(withFriction.systemPrompt).toContain('Friction Reporting'); - expect(withFriction.systemPrompt).toContain('incidental papercuts'); - expect(withFriction.systemPrompt).toContain('Keep working after reporting friction unless'); + // 2026-05-10 rewrite: friction guidance is now action-trigger framed + // without negative scoping — assert by the new content. Section + // heading + the "when in doubt, report" calibration anchor + the + // non-blocking semantic. + expect(withFriction.systemPrompt).toContain('## Friction Reporting'); + expect(withFriction.systemPrompt).toContain('When in doubt, report'); + expect(withFriction.systemPrompt).toContain( + 'only let friction block your task if it actually blocks it', + ); mockResolveEffectiveCapabilities.mockReturnValueOnce(['fs:read']); From 8ec4dc2f0d4cd568258f3a1ae8a2c382005f63cb Mon Sep 17 00:00:00 2001 From: aaight Date: Sun, 10 May 2026 09:56:08 +0200 Subject: [PATCH 3/3] feat(pm-wizard): drive save and verify from provider metadata (#1304) Co-authored-by: Cascade Bot --- tests/unit/web/pm-wizard-hooks.test.ts | 158 ++++++++++++++++ .../components/projects/pm-wizard-hooks.ts | 170 +++++++++--------- web/src/components/projects/pm-wizard.tsx | 13 +- 3 files changed, 255 insertions(+), 86 deletions(-) diff --git a/tests/unit/web/pm-wizard-hooks.test.ts b/tests/unit/web/pm-wizard-hooks.test.ts index f0d45a704..019ce45cb 100644 --- a/tests/unit/web/pm-wizard-hooks.test.ts +++ b/tests/unit/web/pm-wizard-hooks.test.ts @@ -10,8 +10,12 @@ import { jiraProviderWizard } from '../../../web/src/components/projects/pm-prov import { linearProviderWizard } from '../../../web/src/components/projects/pm-providers/linear/wizard.js'; import { trelloProviderWizard } from '../../../web/src/components/projects/pm-providers/trello/wizard.js'; import { + buildCurrentUserDiscoveryRequest, + buildIntegrationUpsertInput, + buildPersistedCredentialInputs, buildProviderAuthArg, buildProviderAuthArgFromMetadata, + formatVerificationDisplay, runPerLabelCreations, } from '../../../web/src/components/projects/pm-wizard-hooks.js'; import type { WizardState } from '../../../web/src/components/projects/pm-wizard-state.js'; @@ -287,6 +291,160 @@ describe('provider credential metadata', () => { }); }); +// ============================================================================ +// Metadata-driven verification +// ============================================================================ + +describe('metadata-driven verification request', () => { + function trelloState(overrides: Partial = {}): WizardState { + return { ...createInitialState(), provider: 'trello', ...overrides }; + } + function jiraState(overrides: Partial = {}): WizardState { + return { ...createInitialState(), provider: 'jira', ...overrides }; + } + function linearState(overrides: Partial = {}): WizardState { + return { ...createInitialState(), provider: 'linear', ...overrides }; + } + + it('builds stored-credential currentUser discovery requests from provider metadata', () => { + expect( + buildCurrentUserDiscoveryRequest( + trelloState({ isEditing: true, hasStoredCredentials: true, trelloApiKey: '' }), + 'proj-t', + trelloProviderWizard, + ), + ).toEqual({ + providerId: 'trello', + capability: 'currentUser', + args: {}, + projectId: 'proj-t', + }); + }); + + it('builds raw-credential currentUser discovery requests from provider metadata', () => { + expect( + buildCurrentUserDiscoveryRequest( + jiraState({ + jiraEmail: 'user@example.com', + jiraApiToken: 'jira-token', + jiraBaseUrl: 'https://example.atlassian.net', + }), + 'proj-j', + jiraProviderWizard, + ), + ).toEqual({ + providerId: 'jira', + capability: 'currentUser', + args: {}, + credentials: { + email: 'user@example.com', + api_token: 'jira-token', + base_url: 'https://example.atlassian.net', + }, + }); + expect( + buildCurrentUserDiscoveryRequest( + linearState({ linearApiKey: 'lin-key' }), + 'proj-l', + linearProviderWizard, + ), + ).toEqual({ + providerId: 'linear', + capability: 'currentUser', + args: {}, + credentials: { api_key: 'lin-key' }, + }); + }); + + it('preserves provider-specific verified-as display formatting', () => { + expect( + formatVerificationDisplay('trello', { id: '1', name: 'Full Name', displayName: 'user' }), + ).toBe('@user (Full Name)'); + expect( + formatVerificationDisplay('jira', { + id: '2', + name: 'Jira User', + displayName: 'user@example.com', + }), + ).toBe('Jira User (user@example.com)'); + expect( + formatVerificationDisplay('linear', { id: '3', name: 'Linear User', displayName: 'lin' }), + ).toBe('lin'); + expect(formatVerificationDisplay('linear', { id: '4', name: 'Linear User' })).toBe( + 'Linear User', + ); + }); +}); + +// ============================================================================ +// Metadata-driven save +// ============================================================================ + +describe('metadata-driven save payloads', () => { + function trelloState(overrides: Partial = {}): WizardState { + return { + ...createInitialState(), + provider: 'trello', + trelloApiKey: 'key', + trelloToken: 'token', + trelloBoardId: 'board-1', + trelloListMappings: { todo: 'list-todo' }, + ...overrides, + }; + } + function jiraState(overrides: Partial = {}): WizardState { + return { + ...createInitialState(), + provider: 'jira', + jiraEmail: 'user@example.com', + jiraApiToken: 'jira-token', + jiraBaseUrl: 'https://example.atlassian.net', + jiraProjectKey: 'PROJ', + jiraStatusMappings: { todo: 'To Do' }, + ...overrides, + }; + } + + it('persists integration config through manifestDef.buildIntegrationConfig', () => { + const state = trelloState(); + expect(buildIntegrationUpsertInput('proj-1', state, trelloProviderWizard)).toEqual({ + projectId: 'proj-1', + category: 'pm', + provider: 'trello', + config: { + boardId: 'board-1', + lists: { todo: 'list-todo' }, + labels: {}, + }, + }); + }); + + it('persists credential values through provider credential metadata', () => { + expect(buildPersistedCredentialInputs(trelloState(), trelloProviderWizard)).toEqual([ + { envVarKey: 'TRELLO_API_KEY', value: 'key', name: 'Trello API Key' }, + { envVarKey: 'TRELLO_TOKEN', value: 'token', name: 'Trello Token' }, + ]); + expect(buildPersistedCredentialInputs(jiraState(), jiraProviderWizard)).toEqual([ + { envVarKey: 'JIRA_EMAIL', value: 'user@example.com', name: 'JIRA Email' }, + { envVarKey: 'JIRA_API_TOKEN', value: 'jira-token', name: 'JIRA API Token' }, + ]); + }); + + it('skips empty credential values so stored credentials remain untouched on edit', () => { + expect( + buildPersistedCredentialInputs( + jiraState({ + isEditing: true, + hasStoredCredentials: true, + jiraEmail: '', + jiraApiToken: '', + }), + jiraProviderWizard, + ), + ).toEqual([]); + }); +}); + // ============================================================================ // buildTrelloIntegrationConfig // ============================================================================ diff --git a/web/src/components/projects/pm-wizard-hooks.ts b/web/src/components/projects/pm-wizard-hooks.ts index 82827e87c..d14872330 100644 --- a/web/src/components/projects/pm-wizard-hooks.ts +++ b/web/src/components/projects/pm-wizard-hooks.ts @@ -16,21 +16,15 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useEffect } from 'react'; import { trpc, trpcClient } from '@/lib/trpc.js'; import { getCredentialRoles } from '../../../../src/config/integrationRoles.js'; -import type { ProviderAuthMetadata } from './pm-providers/types.js'; +import type { ProviderAuthMetadata, ProviderWizardDefinition } from './pm-providers/types.js'; import type { LinearProjectOption, LinearTeamDetails, LinearTeamOption, - Provider, WizardAction, WizardState, } from './pm-wizard-state.js'; -import { - buildJiraIntegrationConfig, - buildLinearIntegrationConfig, - buildTrelloIntegrationConfig, - shouldUseStoredCredentials, -} from './pm-wizard-state.js'; +import { shouldUseStoredCredentials } from './pm-wizard-state.js'; // ============================================================================ // Auth-arg builder — shared across all mutations @@ -630,11 +624,46 @@ export function useLinearDiscovery( // Verification // ============================================================================ +export function buildCurrentUserDiscoveryRequest( + state: WizardState, + projectId: string, + manifestDef: ProviderWizardDefinition, +): { + providerId: string; + capability: 'currentUser'; + args: Record; +} & ({ projectId: string } | { credentials: Record }) { + return { + providerId: manifestDef.id, + capability: 'currentUser', + args: {}, + ...buildProviderAuthArgFromMetadata(state, projectId, manifestDef.auth), + }; +} + +export function formatVerificationDisplay( + provider: string, + me: { id: string; name: string; displayName?: string }, +): string { + // Per-provider display formatting mirrors the pre-009/5 UX: + // Trello: "@{username} ({fullName})" — displayName is username + // JIRA: "{displayName} ({email})" — displayName is email + // Linear: "{displayName || name}" — displayName is the preferred handle + if (provider === 'trello') { + return me.displayName ? `@${me.displayName} (${me.name})` : me.name; + } + if (provider === 'jira') { + return me.displayName ? `${me.name} (${me.displayName})` : me.name; + } + return me.displayName || me.name; +} + export function useVerification( state: WizardState, dispatch: React.Dispatch, advanceToStep: (step: number) => void, projectId: string, + manifestDef: ProviderWizardDefinition, ) { const verifyMutation = useMutation({ mutationFn: async () => { @@ -642,38 +671,23 @@ export function useVerification( // Calls the `currentUser` discovery capability; every provider // maps its native `getMe()` response to `{ id, name, displayName? }`. // - // Edit-mode fallback: `buildProviderAuthArg` returns `{ projectId }` - // when the user is editing with stored credentials but an empty - // API-key field, so the backend resolves the stored secret via - // `resolvePMCredentials` instead of requiring re-entry. - const provider = state.provider; - const authArg = buildProviderAuthArg(state, projectId); - const me = (await trpcClient.pm.discovery.discover.mutate({ - providerId: provider, - capability: 'currentUser', - args: {}, - ...authArg, - })) as { id: string; name: string; displayName?: string }; - return { provider, me }; + // Edit-mode fallback comes from the provider-owned auth metadata: + // empty raw credential fields in edit mode send `{ projectId }`, + // letting the backend resolve stored project credentials. + const request = buildCurrentUserDiscoveryRequest(state, projectId, manifestDef); + const me = (await trpcClient.pm.discovery.discover.mutate(request)) as { + id: string; + name: string; + displayName?: string; + }; + return { provider: manifestDef.id, me }; }, onSuccess: ({ provider, me }) => { // Ignore if provider changed while we were verifying if (provider !== state.provider) return; - // Per-provider display formatting mirrors the pre-009/5 UX: - // Trello: "@{username} ({fullName})" — displayName is username - // JIRA: "{displayName} ({email})" — displayName is email - // Linear: "{displayName || name}" — displayName is the preferred handle - let display: string; - if (provider === 'trello') { - display = me.displayName ? `@${me.displayName} (${me.name})` : me.name; - } else if (provider === 'jira') { - display = me.displayName ? `${me.name} (${me.displayName})` : me.name; - } else { - display = me.displayName || me.name; - } dispatch({ type: 'SET_VERIFICATION', - result: { provider, display }, + result: { provider, display: formatVerificationDisplay(provider, me) }, }); advanceToStep(3); }, @@ -794,63 +808,51 @@ export function useJiraCustomFieldCreation( // Save Mutation — data-driven, no per-provider branching // ============================================================================ -type CredentialEntry = { envVarKey: string; stateField: keyof WizardState; label: string }; +export function buildPersistedCredentialInputs( + state: WizardState, + manifestDef: ProviderWizardDefinition, +): Array<{ envVarKey: string; value: string; name: string }> { + return manifestDef.credentialPersistence.flatMap((cred) => { + const rawValue = state[cred.stateField]; + const value = typeof rawValue === 'string' ? rawValue : ''; + return value ? [{ envVarKey: cred.envVarKey, value, name: cred.label }] : []; + }); +} -const SAVE_CONFIGS: Record< - Provider, - { - buildConfig: (state: WizardState) => Record; - credentials: CredentialEntry[]; - } -> = { - trello: { - buildConfig: buildTrelloIntegrationConfig, - credentials: [ - { envVarKey: 'TRELLO_API_KEY', stateField: 'trelloApiKey', label: 'Trello API Key' }, - { envVarKey: 'TRELLO_TOKEN', stateField: 'trelloToken', label: 'Trello Token' }, - ], - }, - jira: { - buildConfig: buildJiraIntegrationConfig, - credentials: [ - { envVarKey: 'JIRA_EMAIL', stateField: 'jiraEmail', label: 'JIRA Email' }, - { envVarKey: 'JIRA_API_TOKEN', stateField: 'jiraApiToken', label: 'JIRA API Token' }, - ], - }, - linear: { - buildConfig: buildLinearIntegrationConfig, - credentials: [ - { envVarKey: 'LINEAR_API_KEY', stateField: 'linearApiKey', label: 'Linear API Key' }, - ], - }, -}; - -export function useSaveMutation(projectId: string, state: WizardState) { +export function buildIntegrationUpsertInput( + projectId: string, + state: WizardState, + manifestDef: ProviderWizardDefinition, +): { + projectId: string; + category: 'pm'; + provider: string; + config: Record; +} { + return { + projectId, + category: 'pm', + provider: manifestDef.id, + config: manifestDef.buildIntegrationConfig(state), + }; +} + +export function useSaveMutation( + projectId: string, + state: WizardState, + manifestDef: ProviderWizardDefinition, +) { const queryClient = useQueryClient(); const saveMutation = useMutation({ mutationFn: async () => { - const providerCfg = SAVE_CONFIGS[state.provider]; - const config = providerCfg.buildConfig(state); - - const result = await trpcClient.projects.integrations.upsert.mutate({ - projectId, - category: 'pm', - provider: state.provider, - config, - }); + const result = await trpcClient.projects.integrations.upsert.mutate( + buildIntegrationUpsertInput(projectId, state, manifestDef), + ); // Persist credentials to project_credentials table - for (const cred of providerCfg.credentials) { - const value = state[cred.stateField] as string; - if (value) { - await trpcClient.projects.credentials.set.mutate({ - projectId, - envVarKey: cred.envVarKey, - value, - name: cred.label, - }); - } + for (const cred of buildPersistedCredentialInputs(state, manifestDef)) { + await trpcClient.projects.credentials.set.mutate({ projectId, ...cred }); } // On first-time setup, auto-enable default PM triggers for the three main agents diff --git a/web/src/components/projects/pm-wizard.tsx b/web/src/components/projects/pm-wizard.tsx index 61856f140..453e5f73f 100644 --- a/web/src/components/projects/pm-wizard.tsx +++ b/web/src/components/projects/pm-wizard.tsx @@ -234,13 +234,22 @@ export function PMWizard({ // provider's useProviderHooks internally). Unregistered providers fall // through to the legacy per-provider branches. const manifestDef = getProviderWizard(state.provider); + if (!manifestDef) { + throw new Error(`No PM provider wizard registered for ${state.provider}`); + } - const { verifyMutation } = useVerification(state, dispatch, advanceToStep, projectId); + const { verifyMutation } = useVerification( + state, + dispatch, + advanceToStep, + projectId, + manifestDef, + ); // Every PM provider (Trello 006/2, JIRA 006/3, Linear 006/4) composes its // discovery / label / custom-field / webhook hooks inside its own // useProviderHooks. The parent wizard no longer calls any provider- // specific React hook. - const { saveMutation } = useSaveMutation(projectId, state); + const { saveMutation } = useSaveMutation(projectId, state, manifestDef); // ---- Step status ----