diff --git a/src/agents/shared/frictionGuidance.ts b/src/agents/shared/frictionGuidance.ts index 355cf567..24d794cf 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 b38103cd..83f89762 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 8cc9829f..10a76251 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']); diff --git a/tests/unit/web/pm-provider-registry.test.ts b/tests/unit/web/pm-provider-registry.test.ts index 665a5bcb..a6e9267a 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 442178f5..730638d0 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 81f1927c..019ce45c 100644 --- a/tests/unit/web/pm-wizard-hooks.test.ts +++ b/tests/unit/web/pm-wizard-hooks.test.ts @@ -6,8 +6,16 @@ */ 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 { + 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'; @@ -154,6 +162,289 @@ 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', + ); + }); +}); + +// ============================================================================ +// 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-providers/jira/wizard.ts b/web/src/components/projects/pm-providers/jira/wizard.ts index 085e4365..7a278cff 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 06a8f684..b50d045b 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 013640bb..97482d14 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 045fcb4d..506625be 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 c34f3ef2..d1487233 100644 --- a/web/src/components/projects/pm-wizard-hooks.ts +++ b/web/src/components/projects/pm-wizard-hooks.ts @@ -16,20 +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, 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 @@ -75,6 +70,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 // ============================================================================ @@ -605,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 () => { @@ -617,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); }, @@ -769,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 61856f14..453e5f73 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 ----