From d566a7c7848c33124477a74a1e5eac4bfb50c331 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Mon, 23 Feb 2026 16:29:11 +0000 Subject: [PATCH] feat(dashboard): add PM integration wizard with discovery API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the free-text PM integration form with a guided 6-step wizard that discovers Trello boards/JIRA projects via live API calls, letting users pick from dropdowns instead of manually entering IDs. Backend: - Add integrationsDiscovery tRPC router with 6 procedures (verify, boards/projects, board/project details) for both Trello and JIRA - Extract resolveTrelloCreds/resolveJiraCreds DRY helpers - Add input validation (boardId regex, projectKey regex) - Add new Trello client methods (getBoards, getBoardLists, getBoardLabels, getBoardCustomFields) - Add new JIRA client methods (searchProjects, getProjectStatuses, getFields) Frontend (pm-wizard.tsx): - 6-step wizard: Provider → Credentials → Board/Project → Field Mapping → Webhooks → Save - useReducer state management with verifyError in reducer (clears on credential change) - Fix verify race condition: capture provider at mutation start - Fix edit mode: auto-fetch board/project details when editing - Fix SearchableSelect: always include selected value in filter - Replace ?? 0 credential fallbacks with early throw - Fix InlineCredentialCreator: await cache invalidation before selecting new credential - Use isEditing flag: disable provider switch, show "Update" button Other: - Fix pre-existing progressMonitor.ts lint formatting - Add progress model timeout + state file cleared detection - Bump squint to 1.10.2 in Dockerfile.worker Tests: - Add integrationsDiscovery router tests (27 tests) - Add Trello client tests for new board discovery methods - Add JIRA client tests for searchProjects, getProjectStatuses, getFields - Update router.test.ts for integrationsDiscovery sub-router Co-Authored-By: Claude Opus 4.6 --- Dockerfile.worker | 2 +- src/api/router.ts | 2 + src/api/routers/integrationsDiscovery.ts | 183 ++ src/backends/progressMonitor.ts | 36 +- src/jira/client.ts | 42 + src/trello/client.ts | 86 + tests/unit/api/router.test.ts | 36 + .../api/routers/integrationsDiscovery.test.ts | 491 +++++ tests/unit/backends/progress.test.ts | 45 + tests/unit/jira/client.test.ts | 133 ++ tests/unit/trello/client.test.ts | 145 ++ .../components/projects/integration-form.tsx | 503 +---- web/src/components/projects/pm-wizard.tsx | 1704 +++++++++++++++++ 13 files changed, 2901 insertions(+), 507 deletions(-) create mode 100644 src/api/routers/integrationsDiscovery.ts create mode 100644 tests/unit/api/routers/integrationsDiscovery.test.ts create mode 100644 web/src/components/projects/pm-wizard.tsx diff --git a/Dockerfile.worker b/Dockerfile.worker index 0fb43bba..4e068eeb 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -16,7 +16,7 @@ FROM zbigniew1/niu-browser-base:latest AS production WORKDIR /app # Install pnpm and squint globally (some repos use pnpm, squint for codebase analysis) -RUN npm install -g pnpm @zbigniewsobiecki/squint@^1.7.0 --force +RUN npm install -g pnpm @zbigniewsobiecki/squint@^1.10.2 --force # Install additional tools not in niu-browser-base # Note: PostgreSQL is NOT installed - workers connect to external PostgreSQL diff --git a/src/api/router.ts b/src/api/router.ts index 3672e085..6c496442 100644 --- a/src/api/router.ts +++ b/src/api/router.ts @@ -2,6 +2,7 @@ import { agentConfigsRouter } from './routers/agentConfigs.js'; import { authRouter } from './routers/auth.js'; import { credentialsRouter } from './routers/credentials.js'; import { defaultsRouter } from './routers/defaults.js'; +import { integrationsDiscoveryRouter } from './routers/integrationsDiscovery.js'; import { organizationRouter } from './routers/organization.js'; import { projectsRouter } from './routers/projects.js'; import { promptsRouter } from './routers/prompts.js'; @@ -21,6 +22,7 @@ export const appRouter = router({ prompts: promptsRouter, webhooks: webhooksRouter, webhookLogs: webhookLogsRouter, + integrationsDiscovery: integrationsDiscoveryRouter, }); export type AppRouter = typeof appRouter; diff --git a/src/api/routers/integrationsDiscovery.ts b/src/api/routers/integrationsDiscovery.ts new file mode 100644 index 00000000..8249fdc2 --- /dev/null +++ b/src/api/routers/integrationsDiscovery.ts @@ -0,0 +1,183 @@ +import { TRPCError } from '@trpc/server'; +import { eq } from 'drizzle-orm'; +import { z } from 'zod'; +import { getDb } from '../../db/client.js'; +import { decryptCredential } from '../../db/crypto.js'; +import { credentials } from '../../db/schema/index.js'; +import { jiraClient, withJiraCredentials } from '../../jira/client.js'; +import { trelloClient, withTrelloCredentials } from '../../trello/client.js'; +import { logger } from '../../utils/logging.js'; +import { protectedProcedure, router } from '../trpc.js'; + +async function resolveCredentialValue(credentialId: number, orgId: string): Promise { + const db = getDb(); + const [cred] = await db + .select({ orgId: credentials.orgId, value: credentials.value }) + .from(credentials) + .where(eq(credentials.id, credentialId)); + if (!cred || cred.orgId !== orgId) { + throw new TRPCError({ code: 'NOT_FOUND', message: `Credential ${credentialId} not found` }); + } + return decryptCredential(cred.value, cred.orgId); +} + +const trelloCredsInput = z.object({ + apiKeyCredentialId: z.number(), + tokenCredentialId: z.number(), +}); + +const jiraCredsInput = z.object({ + emailCredentialId: z.number(), + apiTokenCredentialId: z.number(), + baseUrl: z.string().url(), +}); + +async function resolveTrelloCreds(input: z.infer, orgId: string) { + const [apiKey, token] = await Promise.all([ + resolveCredentialValue(input.apiKeyCredentialId, orgId), + resolveCredentialValue(input.tokenCredentialId, orgId), + ]); + return { apiKey, token }; +} + +async function resolveJiraCreds(input: z.infer, orgId: string) { + const [email, apiToken] = await Promise.all([ + resolveCredentialValue(input.emailCredentialId, orgId), + resolveCredentialValue(input.apiTokenCredentialId, orgId), + ]); + return { email, apiToken, baseUrl: input.baseUrl }; +} + +export const integrationsDiscoveryRouter = router({ + verifyTrello: protectedProcedure.input(trelloCredsInput).mutation(async ({ ctx, input }) => { + logger.debug('integrationsDiscovery.verifyTrello called', { orgId: ctx.effectiveOrgId }); + const creds = await resolveTrelloCreds(input, ctx.effectiveOrgId); + + try { + const me = await withTrelloCredentials(creds, () => trelloClient.getMe()); + return { id: me.id, fullName: me.fullName, username: me.username }; + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Failed to verify Trello credentials: ${err instanceof Error ? err.message : String(err)}`, + }); + } + }), + + verifyJira: protectedProcedure.input(jiraCredsInput).mutation(async ({ ctx, input }) => { + logger.debug('integrationsDiscovery.verifyJira called', { orgId: ctx.effectiveOrgId }); + const creds = await resolveJiraCreds(input, ctx.effectiveOrgId); + + try { + const me = await withJiraCredentials(creds, () => jiraClient.getMyself()); + return { + displayName: (me as { displayName?: string }).displayName ?? '', + emailAddress: (me as { emailAddress?: string }).emailAddress ?? '', + accountId: (me as { accountId?: string }).accountId ?? '', + }; + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Failed to verify JIRA credentials: ${err instanceof Error ? err.message : String(err)}`, + }); + } + }), + + trelloBoards: protectedProcedure.input(trelloCredsInput).mutation(async ({ ctx, input }) => { + logger.debug('integrationsDiscovery.trelloBoards called', { orgId: ctx.effectiveOrgId }); + const creds = await resolveTrelloCreds(input, ctx.effectiveOrgId); + + try { + return await withTrelloCredentials(creds, () => trelloClient.getBoards()); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Failed to fetch Trello boards: ${err instanceof Error ? err.message : String(err)}`, + }); + } + }), + + trelloBoardDetails: protectedProcedure + .input( + trelloCredsInput.extend({ + boardId: z + .string() + .regex(/^[a-zA-Z0-9]+$/) + .max(32), + }), + ) + .mutation(async ({ ctx, input }) => { + logger.debug('integrationsDiscovery.trelloBoardDetails called', { + orgId: ctx.effectiveOrgId, + boardId: input.boardId, + }); + const creds = await resolveTrelloCreds(input, ctx.effectiveOrgId); + + try { + const [lists, labels, customFields] = await withTrelloCredentials(creds, () => + Promise.all([ + trelloClient.getBoardLists(input.boardId), + trelloClient.getBoardLabels(input.boardId), + trelloClient.getBoardCustomFields(input.boardId), + ]), + ); + return { lists, labels, customFields }; + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Failed to fetch Trello board details: ${err instanceof Error ? err.message : String(err)}`, + }); + } + }), + + jiraProjects: protectedProcedure.input(jiraCredsInput).mutation(async ({ ctx, input }) => { + logger.debug('integrationsDiscovery.jiraProjects called', { orgId: ctx.effectiveOrgId }); + const creds = await resolveJiraCreds(input, ctx.effectiveOrgId); + + try { + return await withJiraCredentials(creds, () => jiraClient.searchProjects()); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Failed to fetch JIRA projects: ${err instanceof Error ? err.message : String(err)}`, + }); + } + }), + + jiraProjectDetails: protectedProcedure + .input( + jiraCredsInput.extend({ + projectKey: z + .string() + .regex(/^[A-Z][A-Z0-9_]+$/) + .max(10), + }), + ) + .mutation(async ({ ctx, input }) => { + logger.debug('integrationsDiscovery.jiraProjectDetails called', { + orgId: ctx.effectiveOrgId, + projectKey: input.projectKey, + }); + const creds = await resolveJiraCreds(input, ctx.effectiveOrgId); + + try { + const [statuses, issueTypes, fields] = await withJiraCredentials(creds, () => + Promise.all([ + jiraClient.getProjectStatuses(input.projectKey), + jiraClient.getIssueTypesForProject(input.projectKey), + jiraClient.getFields(), + ]), + ); + return { + statuses, + issueTypes, + fields: fields.filter((f) => f.custom), + }; + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Failed to fetch JIRA project details: ${err instanceof Error ? err.message : String(err)}`, + }); + } + }), +}); diff --git a/src/backends/progressMonitor.ts b/src/backends/progressMonitor.ts index 36434ab2..dfe81755 100644 --- a/src/backends/progressMonitor.ts +++ b/src/backends/progressMonitor.ts @@ -19,8 +19,13 @@ import { getSessionState } from '../gadgets/sessionState.js'; import { loadTodos } from '../gadgets/todo/storage.js'; import { githubClient } from '../github/client.js'; import { getPMProviderOrNull } from '../pm/index.js'; +import { captureException } from '../sentry.js'; import { type ProgressContext, callProgressModel } from './progressModel.js'; -import { clearProgressCommentId, writeProgressCommentId } from './progressState.js'; +import { + clearProgressCommentId, + readProgressCommentId, + writeProgressCommentId, +} from './progressState.js'; import type { LogWriter, ProgressReporter } from './types.js'; export interface ProgressMonitorConfig { @@ -48,6 +53,7 @@ export interface ProgressMonitorConfig { /** Default progressive schedule: 1min, 3min, 5min, then every intervalMinutes */ const DEFAULT_SCHEDULE_MINUTES = [1, 3, 5]; +const PROGRESS_MODEL_TIMEOUT_MS = 20_000; const RING_BUFFER_MAX = 20; const TEXT_SNIPPETS_MAX = 10; const COMPLETED_TASKS_MAX = 5; @@ -274,11 +280,15 @@ export class ProgressMonitor implements ProgressReporter { let summary: string; try { - summary = await callProgressModel( - this.config.progressModel, - progressContext, - this.config.customModels, - ); + summary = await Promise.race([ + callProgressModel(this.config.progressModel, progressContext, this.config.customModels), + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Progress model timed out')), + PROGRESS_MODEL_TIMEOUT_MS, + ), + ), + ]); this.config.logWriter('INFO', 'Progress model generated summary', { elapsedMinutes: Math.round(elapsedMinutes), summaryLength: summary.length, @@ -287,6 +297,9 @@ export class ProgressMonitor implements ProgressReporter { this.config.logWriter('WARN', 'Progress model failed, falling back to template', { error: String(err), }); + captureException(err instanceof Error ? err : new Error(String(err)), { + tags: { source: 'progress_model', agentType: this.config.agentType }, + }); summary = formatStatusMessage( this.currentIteration, this.maxIterations, @@ -318,6 +331,17 @@ export class ProgressMonitor implements ProgressReporter { if (!provider) return; if (this.progressCommentId) { + // If the PostComment gadget (subprocess) cleared the state file, + // the agent has posted its final comment to this ID — do not overwrite. + const stateFile = readProgressCommentId(this.config.repoDir); + if (!stateFile) { + this.config.logWriter('DEBUG', 'State file cleared by agent — skipping progress update', { + commentId: this.progressCommentId, + }); + this.progressCommentId = null; + return; + } + // Subsequent ticks: update the existing comment. // On success, the state file written by postInitialComment() remains // valid (same comment ID), so no need to rewrite it here. diff --git a/src/jira/client.ts b/src/jira/client.ts index 0fc0e1fb..007cdff0 100644 --- a/src/jira/client.ts +++ b/src/jira/client.ts @@ -114,6 +114,48 @@ export const jiraClient = { })); }, + async searchProjects(): Promise> { + logger.debug('Searching JIRA projects'); + const result = await getClient().projects.searchProjects({ maxResults: 100 }); + const values = (result.values ?? []) as Array<{ key?: string; name?: string }>; + return values.map((p) => ({ + key: p.key ?? '', + name: p.name ?? '', + })); + }, + + async getProjectStatuses(projectKey: string): Promise> { + logger.debug('Fetching JIRA project statuses', { projectKey }); + const result = await getClient().projects.getAllStatuses({ + projectIdOrKey: projectKey, + }); + // getAllStatuses returns issueType-grouped statuses; flatten and deduplicate + const seen = new Set(); + const statuses: Array<{ name: string; id: string }> = []; + for (const issueType of result as Array<{ + statuses?: Array<{ name?: string; id?: string }>; + }>) { + for (const status of issueType.statuses ?? []) { + const name = status.name ?? ''; + if (name && !seen.has(name)) { + seen.add(name); + statuses.push({ name, id: status.id ?? '' }); + } + } + } + return statuses; + }, + + async getFields(): Promise> { + logger.debug('Fetching JIRA fields'); + const fields = await getClient().issueFields.getFields(); + return (fields as Array<{ id?: string; name?: string; custom?: boolean }>).map((f) => ({ + id: f.id ?? '', + name: f.name ?? '', + custom: f.custom ?? false, + })); + }, + async createIssue(fields: Record) { logger.debug('Creating JIRA issue', { project: (fields.project as { key?: string })?.key, diff --git a/src/trello/client.ts b/src/trello/client.ts index decb279f..325b72ab 100644 --- a/src/trello/client.ts +++ b/src/trello/client.ts @@ -480,6 +480,92 @@ export const trelloClient = { })); }, + async getBoards(): Promise> { + logger.debug('Fetching boards for authenticated member'); + const { apiKey, token } = getTrelloCredentials(); + const response = await fetch( + `https://api.trello.com/1/members/me/boards?filter=open&fields=id,name,url&key=${apiKey}&token=${token}`, + ); + if (!response.ok) { + throw new Error(`Failed to fetch boards: ${response.status}`); + } + const boards = (await response.json()) as Array<{ + id?: string; + name?: string; + url?: string; + }>; + return boards.map((b) => ({ + id: b.id || '', + name: b.name || '', + url: b.url || '', + })); + }, + + async getBoardLists(boardId: string): Promise> { + logger.debug('Fetching board lists', { boardId }); + const { apiKey, token } = getTrelloCredentials(); + const response = await fetch( + `https://api.trello.com/1/boards/${boardId}/lists?filter=open&key=${apiKey}&token=${token}`, + ); + if (!response.ok) { + throw new Error(`Failed to fetch board lists: ${response.status}`); + } + const lists = (await response.json()) as Array<{ + id?: string; + name?: string; + }>; + return lists.map((l) => ({ + id: l.id || '', + name: l.name || '', + })); + }, + + async getBoardLabels( + boardId: string, + ): Promise> { + logger.debug('Fetching board labels', { boardId }); + const { apiKey, token } = getTrelloCredentials(); + const response = await fetch( + `https://api.trello.com/1/boards/${boardId}/labels?key=${apiKey}&token=${token}`, + ); + if (!response.ok) { + throw new Error(`Failed to fetch board labels: ${response.status}`); + } + const labels = (await response.json()) as Array<{ + id?: string; + name?: string; + color?: string; + }>; + return labels.map((l) => ({ + id: l.id || '', + name: l.name || '', + color: l.color || '', + })); + }, + + async getBoardCustomFields( + boardId: string, + ): Promise> { + logger.debug('Fetching board custom fields', { boardId }); + const { apiKey, token } = getTrelloCredentials(); + const response = await fetch( + `https://api.trello.com/1/boards/${boardId}/customFields?key=${apiKey}&token=${token}`, + ); + if (!response.ok) { + throw new Error(`Failed to fetch board custom fields: ${response.status}`); + } + const fields = (await response.json()) as Array<{ + id?: string; + name?: string; + type?: string; + }>; + return fields.map((f) => ({ + id: f.id || '', + name: f.name || '', + type: f.type || '', + })); + }, + async updateCardCustomFieldNumber( cardId: string, customFieldId: string, diff --git a/tests/unit/api/router.test.ts b/tests/unit/api/router.test.ts index 105fd882..22858e19 100644 --- a/tests/unit/api/router.test.ts +++ b/tests/unit/api/router.test.ts @@ -76,6 +76,32 @@ vi.mock('../../../src/db/repositories/configRepository.js', () => ({ findProjectByIdFromDb: vi.fn(), })); +vi.mock('../../../src/db/crypto.js', () => ({ + decryptCredential: vi.fn((v: string) => v), +})); + +vi.mock('../../../src/trello/client.js', () => ({ + withTrelloCredentials: vi.fn(), + trelloClient: { + getMe: vi.fn(), + getBoards: vi.fn(), + getBoardLists: vi.fn(), + getBoardLabels: vi.fn(), + getBoardCustomFields: vi.fn(), + }, +})); + +vi.mock('../../../src/jira/client.js', () => ({ + withJiraCredentials: vi.fn(), + jiraClient: { + getMyself: vi.fn(), + searchProjects: vi.fn(), + getProjectStatuses: vi.fn(), + getIssueTypesForProject: vi.fn(), + getFields: vi.fn(), + }, +})); + vi.mock('@octokit/rest', () => ({ Octokit: vi.fn(() => ({ repos: { @@ -157,4 +183,14 @@ describe('appRouter', () => { expect(procedures).toContain('webhooks.create'); expect(procedures).toContain('webhooks.delete'); }); + + it('has integrationsDiscovery sub-router with all procedures', () => { + const procedures = Object.keys(appRouter._def.procedures); + expect(procedures).toContain('integrationsDiscovery.verifyTrello'); + expect(procedures).toContain('integrationsDiscovery.verifyJira'); + expect(procedures).toContain('integrationsDiscovery.trelloBoards'); + expect(procedures).toContain('integrationsDiscovery.trelloBoardDetails'); + expect(procedures).toContain('integrationsDiscovery.jiraProjects'); + expect(procedures).toContain('integrationsDiscovery.jiraProjectDetails'); + }); }); diff --git a/tests/unit/api/routers/integrationsDiscovery.test.ts b/tests/unit/api/routers/integrationsDiscovery.test.ts new file mode 100644 index 00000000..5827aef0 --- /dev/null +++ b/tests/unit/api/routers/integrationsDiscovery.test.ts @@ -0,0 +1,491 @@ +import { TRPCError } from '@trpc/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TRPCContext } from '../../../../src/api/trpc.js'; + +const mockDecryptCredential = vi.fn((value: string) => value); + +vi.mock('../../../../src/db/crypto.js', () => ({ + decryptCredential: (...args: unknown[]) => mockDecryptCredential(...args), +})); + +const mockDbSelect = vi.fn(); +const mockDbFrom = vi.fn(); +const mockDbWhere = vi.fn(); + +vi.mock('../../../../src/db/client.js', () => ({ + getDb: () => ({ + select: mockDbSelect, + }), +})); + +vi.mock('../../../../src/db/schema/index.js', () => ({ + credentials: { id: 'id', orgId: 'org_id', value: 'value' }, +})); + +const mockTrelloGetMe = vi.fn(); +const mockTrelloGetBoards = vi.fn(); +const mockTrelloGetBoardLists = vi.fn(); +const mockTrelloGetBoardLabels = vi.fn(); +const mockTrelloGetBoardCustomFields = vi.fn(); + +vi.mock('../../../../src/trello/client.js', () => ({ + withTrelloCredentials: (...args: unknown[]) => { + const cb = args[1] as () => unknown; + return cb(); + }, + trelloClient: { + getMe: (...args: unknown[]) => mockTrelloGetMe(...args), + getBoards: (...args: unknown[]) => mockTrelloGetBoards(...args), + getBoardLists: (...args: unknown[]) => mockTrelloGetBoardLists(...args), + getBoardLabels: (...args: unknown[]) => mockTrelloGetBoardLabels(...args), + getBoardCustomFields: (...args: unknown[]) => mockTrelloGetBoardCustomFields(...args), + }, +})); + +const mockJiraGetMyself = vi.fn(); +const mockJiraSearchProjects = vi.fn(); +const mockJiraGetProjectStatuses = vi.fn(); +const mockJiraGetIssueTypesForProject = vi.fn(); +const mockJiraGetFields = vi.fn(); + +vi.mock('../../../../src/jira/client.js', () => ({ + withJiraCredentials: (...args: unknown[]) => { + const cb = args[1] as () => unknown; + return cb(); + }, + jiraClient: { + getMyself: (...args: unknown[]) => mockJiraGetMyself(...args), + searchProjects: (...args: unknown[]) => mockJiraSearchProjects(...args), + getProjectStatuses: (...args: unknown[]) => mockJiraGetProjectStatuses(...args), + getIssueTypesForProject: (...args: unknown[]) => mockJiraGetIssueTypesForProject(...args), + getFields: (...args: unknown[]) => mockJiraGetFields(...args), + }, +})); + +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { integrationsDiscoveryRouter } from '../../../../src/api/routers/integrationsDiscovery.js'; + +function createCaller(ctx: TRPCContext) { + return integrationsDiscoveryRouter.createCaller(ctx); +} + +const mockUser = { + id: 'user-1', + orgId: 'org-1', + email: 'test@example.com', + name: 'Test', + role: 'admin', +}; + +const trelloCredsInput = { apiKeyCredentialId: 1, tokenCredentialId: 2 }; +const jiraCredsInput = { + emailCredentialId: 3, + apiTokenCredentialId: 4, + baseUrl: 'https://myorg.atlassian.net', +}; + +/** + * Helper: set up the DB mock chain so that resolveCredentialValue succeeds. + * Each call to getDb().select().from().where() resolves with the given rows. + * Because procedures resolve two credentials via Promise.all, we queue multiple + * return values on mockDbWhere. + */ +function setupDbCredentials(rows: Array<{ orgId: string; value: string }>) { + for (const row of rows) { + mockDbWhere.mockResolvedValueOnce([row]); + } +} + +describe('integrationsDiscoveryRouter', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDbSelect.mockReturnValue({ from: mockDbFrom }); + mockDbFrom.mockReturnValue({ where: mockDbWhere }); + }); + + // ── Auth ───────────────────────────────────────────────────────────── + + describe('auth', () => { + it('verifyTrello throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expect(caller.verifyTrello(trelloCredsInput)).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + + it('verifyJira throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expect(caller.verifyJira(jiraCredsInput)).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + + it('trelloBoards throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expect(caller.trelloBoards(trelloCredsInput)).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + + it('trelloBoardDetails throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expect( + caller.trelloBoardDetails({ ...trelloCredsInput, boardId: 'abc123' }), + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + + it('jiraProjects throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expect(caller.jiraProjects(jiraCredsInput)).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + + it('jiraProjectDetails throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expect( + caller.jiraProjectDetails({ ...jiraCredsInput, projectKey: 'PROJ' }), + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + }); + + // ── Credential resolution ──────────────────────────────────────────── + + describe('credential resolution', () => { + it('throws NOT_FOUND when credential does not exist', async () => { + mockDbWhere.mockResolvedValueOnce([]); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await expect(caller.verifyTrello(trelloCredsInput)).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws NOT_FOUND when credential belongs to different org', async () => { + mockDbWhere.mockResolvedValueOnce([{ orgId: 'different-org', value: 'some-key' }]); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await expect(caller.verifyTrello(trelloCredsInput)).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('calls decryptCredential with value and orgId', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'enc:v1:api-key' }, + { orgId: 'org-1', value: 'enc:v1:token' }, + ]); + mockDecryptCredential.mockReturnValueOnce('decrypted-api-key'); + mockDecryptCredential.mockReturnValueOnce('decrypted-token'); + mockTrelloGetMe.mockResolvedValue({ id: '1', fullName: 'Me', username: 'me' }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await caller.verifyTrello(trelloCredsInput); + + expect(mockDecryptCredential).toHaveBeenCalledWith('enc:v1:api-key', 'org-1'); + expect(mockDecryptCredential).toHaveBeenCalledWith('enc:v1:token', 'org-1'); + }); + }); + + // ── verifyTrello ───────────────────────────────────────────────────── + + describe('verifyTrello', () => { + it('returns username, fullName, and id on success', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'api-key' }, + { orgId: 'org-1', value: 'token' }, + ]); + mockTrelloGetMe.mockResolvedValue({ + id: 'trello-123', + fullName: 'Trello User', + username: 'trellouser', + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.verifyTrello(trelloCredsInput); + + expect(result).toEqual({ + id: 'trello-123', + fullName: 'Trello User', + username: 'trellouser', + }); + }); + + it('wraps API failure in BAD_REQUEST', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'bad-key' }, + { orgId: 'org-1', value: 'bad-token' }, + ]); + mockTrelloGetMe.mockRejectedValue(new Error('Invalid API key')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect(caller.verifyTrello(trelloCredsInput)).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + }); + + // ── verifyJira ─────────────────────────────────────────────────────── + + describe('verifyJira', () => { + it('returns displayName, emailAddress, and accountId on success', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'email@example.com' }, + { orgId: 'org-1', value: 'api-token' }, + ]); + mockJiraGetMyself.mockResolvedValue({ + displayName: 'Jira User', + emailAddress: 'jira@example.com', + accountId: 'acct-456', + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.verifyJira(jiraCredsInput); + + expect(result).toEqual({ + displayName: 'Jira User', + emailAddress: 'jira@example.com', + accountId: 'acct-456', + }); + }); + + it('returns empty strings when JIRA response fields are missing', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'email' }, + { orgId: 'org-1', value: 'token' }, + ]); + mockJiraGetMyself.mockResolvedValue({}); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.verifyJira(jiraCredsInput); + + expect(result).toEqual({ + displayName: '', + emailAddress: '', + accountId: '', + }); + }); + + it('wraps API failure in BAD_REQUEST', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'email' }, + { orgId: 'org-1', value: 'bad-token' }, + ]); + mockJiraGetMyself.mockRejectedValue(new Error('Unauthorized')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect(caller.verifyJira(jiraCredsInput)).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + }); + + // ── trelloBoards ───────────────────────────────────────────────────── + + describe('trelloBoards', () => { + it('returns boards list on success', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'api-key' }, + { orgId: 'org-1', value: 'token' }, + ]); + const boards = [ + { id: 'board-1', name: 'Board One', url: 'https://trello.com/b/1' }, + { id: 'board-2', name: 'Board Two', url: 'https://trello.com/b/2' }, + ]; + mockTrelloGetBoards.mockResolvedValue(boards); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.trelloBoards(trelloCredsInput); + + expect(result).toEqual(boards); + }); + + it('wraps API failure in BAD_REQUEST', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'api-key' }, + { orgId: 'org-1', value: 'token' }, + ]); + mockTrelloGetBoards.mockRejectedValue(new Error('Network error')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect(caller.trelloBoards(trelloCredsInput)).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + }); + + // ── trelloBoardDetails ─────────────────────────────────────────────── + + describe('trelloBoardDetails', () => { + it('returns lists, labels, and customFields on success', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'api-key' }, + { orgId: 'org-1', value: 'token' }, + ]); + const lists = [{ id: 'list-1', name: 'Backlog' }]; + const labels = [{ id: 'label-1', name: 'Bug', color: 'red' }]; + const customFields = [{ id: 'cf-1', name: 'Priority', type: 'list' }]; + mockTrelloGetBoardLists.mockResolvedValue(lists); + mockTrelloGetBoardLabels.mockResolvedValue(labels); + mockTrelloGetBoardCustomFields.mockResolvedValue(customFields); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.trelloBoardDetails({ + ...trelloCredsInput, + boardId: 'abc123', + }); + + expect(result).toEqual({ lists, labels, customFields }); + expect(mockTrelloGetBoardLists).toHaveBeenCalledWith('abc123'); + expect(mockTrelloGetBoardLabels).toHaveBeenCalledWith('abc123'); + expect(mockTrelloGetBoardCustomFields).toHaveBeenCalledWith('abc123'); + }); + + it('rejects boardId with hyphens', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.trelloBoardDetails({ ...trelloCredsInput, boardId: 'abc-def' }), + ).rejects.toThrow(); + }); + + it('rejects boardId longer than 32 characters', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.trelloBoardDetails({ + ...trelloCredsInput, + boardId: 'a'.repeat(33), + }), + ).rejects.toThrow(); + }); + + it('wraps API failure in BAD_REQUEST', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'api-key' }, + { orgId: 'org-1', value: 'token' }, + ]); + mockTrelloGetBoardLists.mockRejectedValue(new Error('Board not found')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.trelloBoardDetails({ ...trelloCredsInput, boardId: 'abc123' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + }); + + // ── jiraProjects ───────────────────────────────────────────────────── + + describe('jiraProjects', () => { + it('returns project list on success', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'email' }, + { orgId: 'org-1', value: 'api-token' }, + ]); + const projects = [ + { key: 'PROJ', name: 'Project One' }, + { key: 'TEST', name: 'Test Project' }, + ]; + mockJiraSearchProjects.mockResolvedValue(projects); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.jiraProjects(jiraCredsInput); + + expect(result).toEqual(projects); + }); + + it('wraps API failure in BAD_REQUEST', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'email' }, + { orgId: 'org-1', value: 'api-token' }, + ]); + mockJiraSearchProjects.mockRejectedValue(new Error('Connection refused')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect(caller.jiraProjects(jiraCredsInput)).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + }); + + // ── jiraProjectDetails ─────────────────────────────────────────────── + + describe('jiraProjectDetails', () => { + it('returns statuses, issueTypes, and only custom fields', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'email' }, + { orgId: 'org-1', value: 'api-token' }, + ]); + const statuses = [ + { name: 'To Do', id: 'status-1' }, + { name: 'Done', id: 'status-2' }, + ]; + const issueTypes = [ + { name: 'Story', subtask: false }, + { name: 'Bug', subtask: false }, + ]; + const fields = [ + { id: 'summary', name: 'Summary', custom: false }, + { id: 'customfield_10001', name: 'Story Points', custom: true }, + { id: 'description', name: 'Description', custom: false }, + { id: 'customfield_10002', name: 'Sprint', custom: true }, + ]; + mockJiraGetProjectStatuses.mockResolvedValue(statuses); + mockJiraGetIssueTypesForProject.mockResolvedValue(issueTypes); + mockJiraGetFields.mockResolvedValue(fields); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.jiraProjectDetails({ + ...jiraCredsInput, + projectKey: 'PROJ', + }); + + expect(result.statuses).toEqual(statuses); + expect(result.issueTypes).toEqual(issueTypes); + expect(result.fields).toEqual([ + { id: 'customfield_10001', name: 'Story Points', custom: true }, + { id: 'customfield_10002', name: 'Sprint', custom: true }, + ]); + expect(mockJiraGetProjectStatuses).toHaveBeenCalledWith('PROJ'); + expect(mockJiraGetIssueTypesForProject).toHaveBeenCalledWith('PROJ'); + }); + + it('rejects lowercase projectKey', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.jiraProjectDetails({ ...jiraCredsInput, projectKey: 'proj' }), + ).rejects.toThrow(); + }); + + it('rejects projectKey starting with number', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.jiraProjectDetails({ ...jiraCredsInput, projectKey: '1TEST' }), + ).rejects.toThrow(); + }); + + it('rejects projectKey longer than 10 characters', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.jiraProjectDetails({ + ...jiraCredsInput, + projectKey: 'ABCDEFGHIJK', + }), + ).rejects.toThrow(); + }); + + it('wraps API failure in BAD_REQUEST', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'email' }, + { orgId: 'org-1', value: 'api-token' }, + ]); + mockJiraGetProjectStatuses.mockRejectedValue(new Error('Project not found')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.jiraProjectDetails({ ...jiraCredsInput, projectKey: 'PROJ' }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + }); +}); diff --git a/tests/unit/backends/progress.test.ts b/tests/unit/backends/progress.test.ts index 7168f16f..7cb97e9d 100644 --- a/tests/unit/backends/progress.test.ts +++ b/tests/unit/backends/progress.test.ts @@ -35,6 +35,7 @@ vi.mock('../../../src/config/statusUpdateConfig.js', () => ({ vi.mock('../../../src/backends/progressState.js', () => ({ writeProgressCommentId: vi.fn(), clearProgressCommentId: vi.fn(), + readProgressCommentId: vi.fn(), })); import { syncCompletedTodosToChecklist } from '../../../src/agents/utils/checklistSync.js'; @@ -43,6 +44,7 @@ import { callProgressModel } from '../../../src/backends/progressModel.js'; import { ProgressMonitor } from '../../../src/backends/progressMonitor.js'; import { clearProgressCommentId, + readProgressCommentId, writeProgressCommentId, } from '../../../src/backends/progressState.js'; import { @@ -59,6 +61,7 @@ import { getPMProviderOrNull } from '../../../src/pm/index.js'; const mockGetPMProvider = vi.mocked(getPMProviderOrNull); const mockWriteProgressCommentId = vi.mocked(writeProgressCommentId); const mockClearProgressCommentId = vi.mocked(clearProgressCommentId); +const mockReadProgressCommentId = vi.mocked(readProgressCommentId); const mockPMProvider = { addComment: vi.fn(), updateComment: vi.fn() }; const mockGithub = vi.mocked(githubClient); const mockGetStatusConfig = vi.mocked(getStatusUpdateConfig); @@ -74,6 +77,8 @@ beforeEach(() => { vi.useFakeTimers(); mockLoadTodos.mockReturnValue([]); mockGetPMProvider.mockReturnValue(null); + // Default: state file exists (not cleared by agent subprocess) + mockReadProgressCommentId.mockReturnValue({ workItemId: 'card1', commentId: 'comment-id-1' }); }); afterEach(() => { @@ -1125,6 +1130,46 @@ describe('ProgressMonitor — state file integration', () => { ); }); + it('skips progress update when state file is cleared by agent subprocess', async () => { + const logWriter = vi.fn(); + const monitor = new ProgressMonitor({ + agentType: 'respond-to-planning-comment', + taskDescription: 'Test task', + intervalMinutes: 5, + progressModel: 'test-model', + customModels: [], + logWriter, + repoDir: '/tmp/test-repo', + trello: { cardId: 'card1' }, + }); + + mockGetPMProvider.mockReturnValue(mockPMProvider as unknown as PMProvider); + mockCallProgressModel.mockResolvedValue('Progress update'); + mockPMProvider.addComment.mockResolvedValue('comment-id-initial'); + mockPMProvider.updateComment.mockResolvedValue(undefined); + + monitor.start(); + await vi.advanceTimersByTimeAsync(0); + + // Simulate the PostComment gadget clearing the state file + mockReadProgressCommentId.mockReturnValue(null); + + // First tick fires at 1 minute — should detect cleared state file and skip + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + monitor.stop(); + + // updateComment should NOT have been called (state file was cleared) + expect(mockPMProvider.updateComment).not.toHaveBeenCalled(); + // Should log the skip + expect(logWriter).toHaveBeenCalledWith( + 'DEBUG', + 'State file cleared by agent — skipping progress update', + expect.objectContaining({ commentId: 'comment-id-initial' }), + ); + // progressCommentId should be cleared + expect(monitor.getProgressCommentId()).toBeNull(); + }); + it('updates state file when new comment is created after update failure', async () => { const logWriter = vi.fn(); const monitor = new ProgressMonitor({ diff --git a/tests/unit/jira/client.test.ts b/tests/unit/jira/client.test.ts index db763e9d..758b8e15 100644 --- a/tests/unit/jira/client.test.ts +++ b/tests/unit/jira/client.test.ts @@ -18,6 +18,7 @@ const { mockIssueRemoteLinks, mockMyself, mockProjects, + mockIssueFields, } = vi.hoisted(() => ({ mockIssues: { getIssue: vi.fn(), @@ -46,6 +47,11 @@ const { }, mockProjects: { getProject: vi.fn(), + searchProjects: vi.fn(), + getAllStatuses: vi.fn(), + }, + mockIssueFields: { + getFields: vi.fn(), }, })); @@ -58,6 +64,7 @@ vi.mock('jira.js', () => ({ issueRemoteLinks: mockIssueRemoteLinks, myself: mockMyself, projects: mockProjects, + issueFields: mockIssueFields, })), })); @@ -92,6 +99,9 @@ describe('jiraClient', () => { mockIssueRemoteLinks.createOrUpdateRemoteIssueLink.mockReset(); mockMyself.getCurrentUser.mockReset(); mockProjects.getProject.mockReset(); + mockProjects.searchProjects.mockReset(); + mockProjects.getAllStatuses.mockReset(); + mockIssueFields.getFields.mockReset(); _resetCloudIdCache(); }); @@ -657,6 +667,129 @@ describe('jiraClient', () => { }); }); + describe('searchProjects', () => { + it('returns project keys and names', async () => { + mockProjects.searchProjects.mockResolvedValue({ + values: [ + { key: 'PROJ', name: 'My Project' }, + { key: 'TEST', name: 'Test Project' }, + ], + }); + + const result = await withJiraCredentials(creds, () => jiraClient.searchProjects()); + + expect(result).toEqual([ + { key: 'PROJ', name: 'My Project' }, + { key: 'TEST', name: 'Test Project' }, + ]); + expect(mockProjects.searchProjects).toHaveBeenCalledWith({ maxResults: 100 }); + }); + + it('handles missing fields gracefully', async () => { + mockProjects.searchProjects.mockResolvedValue({ + values: [{}, { key: 'X' }], + }); + + const result = await withJiraCredentials(creds, () => jiraClient.searchProjects()); + + expect(result).toEqual([ + { key: '', name: '' }, + { key: 'X', name: '' }, + ]); + }); + + it('returns empty array when values is missing', async () => { + mockProjects.searchProjects.mockResolvedValue({}); + + const result = await withJiraCredentials(creds, () => jiraClient.searchProjects()); + + expect(result).toEqual([]); + }); + }); + + describe('getProjectStatuses', () => { + it('flattens and deduplicates statuses across issue types', async () => { + mockProjects.getAllStatuses.mockResolvedValue([ + { + statuses: [ + { name: 'To Do', id: '1' }, + { name: 'In Progress', id: '2' }, + ], + }, + { + statuses: [ + { name: 'In Progress', id: '2' }, + { name: 'Done', id: '3' }, + ], + }, + ]); + + const result = await withJiraCredentials(creds, () => jiraClient.getProjectStatuses('PROJ')); + + expect(result).toEqual([ + { name: 'To Do', id: '1' }, + { name: 'In Progress', id: '2' }, + { name: 'Done', id: '3' }, + ]); + expect(mockProjects.getAllStatuses).toHaveBeenCalledWith({ + projectIdOrKey: 'PROJ', + }); + }); + + it('skips statuses with empty names', async () => { + mockProjects.getAllStatuses.mockResolvedValue([ + { + statuses: [ + { name: '', id: '0' }, + { name: 'Open', id: '1' }, + ], + }, + ]); + + const result = await withJiraCredentials(creds, () => jiraClient.getProjectStatuses('PROJ')); + + expect(result).toEqual([{ name: 'Open', id: '1' }]); + }); + + it('handles missing statuses array in issue type', async () => { + mockProjects.getAllStatuses.mockResolvedValue([ + {}, + { statuses: [{ name: 'Open', id: '1' }] }, + ]); + + const result = await withJiraCredentials(creds, () => jiraClient.getProjectStatuses('PROJ')); + + expect(result).toEqual([{ name: 'Open', id: '1' }]); + }); + }); + + describe('getFields', () => { + it('returns all fields with custom flag', async () => { + mockIssueFields.getFields.mockResolvedValue([ + { id: 'summary', name: 'Summary', custom: false }, + { id: 'customfield_10001', name: 'Story Points', custom: true }, + ]); + + const result = await withJiraCredentials(creds, () => jiraClient.getFields()); + + expect(result).toEqual([ + { id: 'summary', name: 'Summary', custom: false }, + { id: 'customfield_10001', name: 'Story Points', custom: true }, + ]); + }); + + it('handles missing fields gracefully', async () => { + mockIssueFields.getFields.mockResolvedValue([{}, { id: 'x' }]); + + const result = await withJiraCredentials(creds, () => jiraClient.getFields()); + + expect(result).toEqual([ + { id: '', name: '', custom: false }, + { id: 'x', name: '', custom: false }, + ]); + }); + }); + describe('getJiraCredentials', () => { it('throws when called outside scope', () => { expect(() => getJiraCredentials()).toThrow('No JIRA credentials in scope'); diff --git a/tests/unit/trello/client.test.ts b/tests/unit/trello/client.test.ts index cda58ace..142630a6 100644 --- a/tests/unit/trello/client.test.ts +++ b/tests/unit/trello/client.test.ts @@ -338,6 +338,151 @@ describe('trelloClient', () => { }); }); + describe('getBoards', () => { + it('returns boards for authenticated member', async () => { + const boards = [ + { id: 'board-1', name: 'Board One', url: 'https://trello.com/b/board1' }, + { id: 'board-2', name: 'Board Two', url: 'https://trello.com/b/board2' }, + ]; + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify(boards), { status: 200 })); + + const result = await withTrelloCredentials(creds, () => trelloClient.getBoards()); + + expect(result).toEqual(boards); + expect(fetchSpy).toHaveBeenCalledOnce(); + const [url] = fetchSpy.mock.calls[0]; + expect(url).toContain('/1/members/me/boards'); + expect(url).toContain('filter=open'); + expect(url).toContain('key=test-key'); + expect(url).toContain('token=test-token'); + }); + + it('throws on non-OK response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('Unauthorized', { status: 401 }), + ); + + await expect(withTrelloCredentials(creds, () => trelloClient.getBoards())).rejects.toThrow( + 'Failed to fetch boards: 401', + ); + }); + + it('handles missing fields gracefully', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify([{}, { id: 'b1' }]), { status: 200 })); + + const result = await withTrelloCredentials(creds, () => trelloClient.getBoards()); + + expect(result).toEqual([ + { id: '', name: '', url: '' }, + { id: 'b1', name: '', url: '' }, + ]); + }); + }); + + describe('getBoardLists', () => { + it('returns lists for a board', async () => { + const lists = [ + { id: 'list-1', name: 'Backlog' }, + { id: 'list-2', name: 'In Progress' }, + ]; + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify(lists), { status: 200 })); + + const result = await withTrelloCredentials(creds, () => + trelloClient.getBoardLists('board-1'), + ); + + expect(result).toEqual(lists); + const [url] = fetchSpy.mock.calls[0]; + expect(url).toContain('/1/boards/board-1/lists'); + expect(url).toContain('filter=open'); + }); + + it('throws on non-OK response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Not Found', { status: 404 })); + + await expect( + withTrelloCredentials(creds, () => trelloClient.getBoardLists('board-1')), + ).rejects.toThrow('Failed to fetch board lists: 404'); + }); + }); + + describe('getBoardLabels', () => { + it('returns labels for a board', async () => { + const labels = [ + { id: 'label-1', name: 'Bug', color: 'red' }, + { id: 'label-2', name: 'Feature', color: 'green' }, + ]; + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify(labels), { status: 200 })); + + const result = await withTrelloCredentials(creds, () => + trelloClient.getBoardLabels('board-1'), + ); + + expect(result).toEqual(labels); + const [url] = fetchSpy.mock.calls[0]; + expect(url).toContain('/1/boards/board-1/labels'); + }); + + it('throws on non-OK response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Error', { status: 500 })); + + await expect( + withTrelloCredentials(creds, () => trelloClient.getBoardLabels('board-1')), + ).rejects.toThrow('Failed to fetch board labels: 500'); + }); + }); + + describe('getBoardCustomFields', () => { + it('returns custom fields for a board', async () => { + const fields = [ + { id: 'cf-1', name: 'Priority', type: 'list' }, + { id: 'cf-2', name: 'Cost', type: 'number' }, + ]; + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify(fields), { status: 200 })); + + const result = await withTrelloCredentials(creds, () => + trelloClient.getBoardCustomFields('board-1'), + ); + + expect(result).toEqual(fields); + const [url] = fetchSpy.mock.calls[0]; + expect(url).toContain('/1/boards/board-1/customFields'); + }); + + it('throws on non-OK response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Error', { status: 403 })); + + await expect( + withTrelloCredentials(creds, () => trelloClient.getBoardCustomFields('board-1')), + ).rejects.toThrow('Failed to fetch board custom fields: 403'); + }); + + it('handles missing fields gracefully', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify([{}, { id: 'cf-1', type: 'text' }]), { status: 200 }), + ); + + const result = await withTrelloCredentials(creds, () => + trelloClient.getBoardCustomFields('board-1'), + ); + + expect(result).toEqual([ + { id: '', name: '', type: '' }, + { id: 'cf-1', name: '', type: 'text' }, + ]); + }); + }); + describe('getCardAttachments', () => { it('returns attachments via fetch', async () => { const attachments = [ diff --git a/web/src/components/projects/integration-form.tsx b/web/src/components/projects/integration-form.tsx index 0db0b7fa..98e3e067 100644 --- a/web/src/components/projects/integration-form.tsx +++ b/web/src/components/projects/integration-form.tsx @@ -1,86 +1,9 @@ -import { Input } from '@/components/ui/input.js'; import { Label } from '@/components/ui/label.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { CheckCircle, Loader2, Plus, Trash2, XCircle } from 'lucide-react'; +import { CheckCircle, Loader2, XCircle } from 'lucide-react'; import { useEffect, useState } from 'react'; - -interface KVPair { - key: string; - value: string; -} - -function KeyValueEditor({ - label, - pairs, - onChange, -}: { - label: string; - pairs: KVPair[]; - onChange: (pairs: KVPair[]) => void; -}) { - return ( -
-
- - -
- {pairs.map((pair, i) => ( -
- { - const next = [...pairs]; - next[i] = { ...next[i], key: e.target.value }; - onChange(next); - }} - placeholder="Key" - className="flex-1" - /> - { - const next = [...pairs]; - next[i] = { ...next[i], value: e.target.value }; - onChange(next); - }} - placeholder="Value" - className="flex-1" - /> - -
- ))} - {pairs.length === 0 &&

No entries

} -
- ); -} - -function toKVPairs(obj: Record | undefined): KVPair[] { - if (!obj) return []; - return Object.entries(obj).map(([key, value]) => ({ key, value })); -} - -function fromKVPairs(pairs: KVPair[]): Record { - const result: Record = {}; - for (const pair of pairs) { - if (pair.key.trim()) { - result[pair.key.trim()] = pair.value; - } - } - return result; -} +import { PMWizard } from './pm-wizard.js'; type IntegrationCategory = 'pm' | 'scm'; @@ -156,141 +79,6 @@ function CredentialSelector({ ); } -// ============================================================================ -// Known key constants for constrained editors -// ============================================================================ - -interface KeyOption { - value: string; - label: string; -} - -const TRELLO_LIST_KEYS: KeyOption[] = [ - { value: 'briefing', label: 'briefing' }, - { value: 'stories', label: 'stories' }, - { value: 'planning', label: 'planning' }, - { value: 'todo', label: 'todo' }, - { value: 'inProgress', label: 'inProgress' }, - { value: 'inReview', label: 'inReview' }, - { value: 'done', label: 'done' }, - { value: 'merged', label: 'merged' }, - { value: 'debug', label: 'debug' }, -]; - -const TRELLO_LABEL_KEYS: KeyOption[] = [ - { value: 'readyToProcess', label: 'readyToProcess' }, - { value: 'processing', label: 'processing' }, - { value: 'processed', label: 'processed' }, - { value: 'error', label: 'error' }, -]; - -const JIRA_STATUS_KEYS: KeyOption[] = [ - { value: 'briefing', label: 'briefing' }, - { value: 'planning', label: 'planning' }, - { value: 'todo', label: 'todo' }, - { value: 'inProgress', label: 'inProgress' }, - { value: 'inReview', label: 'inReview' }, - { value: 'done', label: 'done' }, - { value: 'merged', label: 'merged' }, -]; - -const JIRA_LABEL_KEYS: KeyOption[] = [ - { value: 'processing', label: 'processing' }, - { value: 'processed', label: 'processed' }, - { value: 'error', label: 'error' }, - { value: 'readyToProcess', label: 'readyToProcess' }, -]; - -// ============================================================================ -// ConstrainedKeyValueEditor — key column is a dropdown of allowed keys -// ============================================================================ - -function ConstrainedKeyValueEditor({ - label, - pairs, - onChange, - allowedKeys, - valuePlaceholder, -}: { - label: string; - pairs: KVPair[]; - onChange: (pairs: KVPair[]) => void; - allowedKeys: KeyOption[]; - valuePlaceholder?: string; -}) { - const usedKeys = new Set(pairs.map((p) => p.key)); - const availableKeys = allowedKeys.filter((k) => !usedKeys.has(k.value)); - const allUsed = availableKeys.length === 0; - - const handleAdd = () => { - // Pick the first unused allowed key, or empty string if all used - const firstAvailable = availableKeys[0]?.value ?? ''; - onChange([...pairs, { key: firstAvailable, value: '' }]); - }; - - return ( -
-
- - -
- {pairs.map((pair, i) => { - // Keys available for this row: allowed keys not used by OTHER rows - const otherUsedKeys = new Set(pairs.filter((_, j) => j !== i).map((p) => p.key)); - // Build options: all allowed keys not used elsewhere + current key if it's custom - const rowOptions = allowedKeys.filter((k) => !otherUsedKeys.has(k.value)); - const isCustomKey = pair.key !== '' && !allowedKeys.some((k) => k.value === pair.key); - - return ( -
- - { - const next = [...pairs]; - next[i] = { ...next[i], value: e.target.value }; - onChange(next); - }} - placeholder={valuePlaceholder ?? 'Value'} - className="flex-1" - /> - -
- ); - })} - {pairs.length === 0 &&

No entries

} -
- ); -} - // ============================================================================ // Provider-specific credential role definitions // ============================================================================ @@ -302,17 +90,6 @@ interface CredentialRoleDef { hasVerify?: boolean; } -const PM_CREDENTIAL_ROLES: Record = { - trello: [ - { role: 'api_key', label: 'API Key', description: 'Trello API Key for authentication.' }, - { role: 'token', label: 'Token', description: 'Trello token for authorization.' }, - ], - jira: [ - { role: 'email', label: 'Email', description: 'JIRA account email for authentication.' }, - { role: 'api_token', label: 'API Token', description: 'JIRA API token for authorization.' }, - ], -}; - const SCM_CREDENTIAL_ROLES: Record = { github: [ { @@ -404,280 +181,6 @@ function IntegrationCredentialSlots({ ); } -// ============================================================================ -// PM Tab (Trello / JIRA) -// ============================================================================ - -function PMTab({ - projectId, - initialProvider, - initialConfig, - initialCredentials, -}: { - projectId: string; - initialProvider: string; - initialConfig?: Record; - initialCredentials: Map; -}) { - const queryClient = useQueryClient(); - - const credentialsQuery = useQuery(trpc.credentials.list.queryOptions()); - const orgCredentials = (credentialsQuery.data ?? []) as CredentialOption[]; - - const [provider, setProvider] = useState(initialProvider || 'trello'); - const [credentialMap, setCredentialMap] = useState>(initialCredentials); - - // Trello fields - const [boardId, setBoardId] = useState(''); - const [lists, setLists] = useState([]); - const [labels, setLabels] = useState([]); - const [costField, setCostField] = useState(''); - - // Jira fields - const [jiraProjectKey, setJiraProjectKey] = useState(''); - const [baseUrl, setBaseUrl] = useState(''); - const [statuses, setStatuses] = useState([]); - const [issueTypes, setIssueTypes] = useState([]); - const [jiraLabels, setJiraLabels] = useState([ - { key: 'processing', value: 'cascade-processing' }, - { key: 'processed', value: 'cascade-processed' }, - { key: 'error', value: 'cascade-error' }, - { key: 'readyToProcess', value: 'cascade-ready' }, - ]); - const [jiraCostField, setJiraCostField] = useState(''); - - useEffect(() => { - if (initialConfig && initialProvider === 'trello') { - setBoardId((initialConfig.boardId as string) ?? ''); - setLists(toKVPairs(initialConfig.lists as Record)); - setLabels(toKVPairs(initialConfig.labels as Record)); - const cf = initialConfig.customFields as Record | undefined; - setCostField(cf?.cost ?? ''); - } else if (initialConfig && initialProvider === 'jira') { - setJiraProjectKey((initialConfig.projectKey as string) ?? ''); - setBaseUrl((initialConfig.baseUrl as string) ?? ''); - setStatuses(toKVPairs(initialConfig.statuses as Record)); - setIssueTypes(toKVPairs(initialConfig.issueTypes as Record)); - const jl = initialConfig.labels as Record | undefined; - if (jl) setJiraLabels(toKVPairs(jl)); - const cf = initialConfig.customFields as Record | undefined; - setJiraCostField(cf?.cost ?? ''); - } - }, [initialConfig, initialProvider]); - - useEffect(() => { - setCredentialMap(initialCredentials); - }, [initialCredentials]); - - const saveMutation = useMutation({ - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: handles multiple provider types + credential linking - mutationFn: async () => { - let config: Record; - if (provider === 'trello') { - config = { - boardId, - lists: fromKVPairs(lists), - labels: fromKVPairs(labels), - ...(costField ? { customFields: { cost: costField } } : {}), - }; - } else { - config = { - projectKey: jiraProjectKey, - baseUrl, - statuses: fromKVPairs(statuses), - ...(issueTypes.length > 0 ? { issueTypes: fromKVPairs(issueTypes) } : {}), - ...(jiraLabels.length > 0 ? { labels: fromKVPairs(jiraLabels) } : {}), - ...(jiraCostField ? { customFields: { cost: jiraCostField } } : {}), - }; - } - - // Note: triggers are intentionally omitted — they are managed via the Agent Configs tab - const result = await trpcClient.projects.integrations.upsert.mutate({ - projectId, - category: 'pm', - provider, - config, - }); - - // Set integration credentials - for (const [role, credentialId] of credentialMap) { - await trpcClient.projects.integrationCredentials.set.mutate({ - projectId, - category: 'pm', - role, - credentialId, - }); - } - - return result; - }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, - }); - queryClient.invalidateQueries({ - queryKey: trpc.projects.integrationCredentials.list.queryOptions({ - projectId, - category: 'pm', - }).queryKey, - }); - }, - }); - - const credentialRoles = PM_CREDENTIAL_ROLES[provider] ?? []; - - return ( -
-
- - -
- - {provider === 'trello' && ( - <> -
- - setBoardId(e.target.value)} - placeholder="Trello board ID" - /> -
- - -
- - setCostField(e.target.value)} - placeholder="Custom field ID for cost tracking" - /> -
- - )} - - {provider === 'jira' && ( - <> -
- - setJiraProjectKey(e.target.value)} - placeholder="e.g., PROJ" - /> -
-
- - setBaseUrl(e.target.value)} - placeholder="https://your-instance.atlassian.net" - /> -
- -

- Map each CASCADE status key to the corresponding JIRA status name. -

- - -

- Map each CASCADE label key to the corresponding JIRA label name. -

-
- - setJiraCostField(e.target.value)} - placeholder="e.g., customfield_10042" - /> -
- - )} - -

- Trigger configuration has moved to the Agent Configs tab. -

- - { - setCredentialMap((prev) => { - const next = new Map(prev); - if (id) { - next.set(role, id); - } else { - next.delete(role); - } - return next; - }); - }} - /> - -
- - {saveMutation.isSuccess && Saved} - {saveMutation.isError && ( - {saveMutation.error.message} - )} -
-
- ); -} - // ============================================================================ // SCM Tab (GitHub) // ============================================================================ @@ -857,7 +360,7 @@ export function IntegrationForm({ projectId }: { projectId: string }) { {activeTab === 'pm' && ( - } diff --git a/web/src/components/projects/pm-wizard.tsx b/web/src/components/projects/pm-wizard.tsx new file mode 100644 index 00000000..1f2b4d5c --- /dev/null +++ b/web/src/components/projects/pm-wizard.tsx @@ -0,0 +1,1704 @@ +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + AlertCircle, + Check, + CheckCircle, + ChevronDown, + ChevronRight, + ExternalLink, + Globe, + Loader2, + Plus, + RefreshCw, + Trash2, + XCircle, +} from 'lucide-react'; +import { type Reducer, useEffect, useReducer, useState } from 'react'; + +// ============================================================================ +// Types +// ============================================================================ + +interface CredentialOption { + id: number; + name: string; + envVarKey: string; + value: string; +} + +interface TrelloBoardOption { + id: string; + name: string; + url: string; +} + +interface TrelloBoardDetails { + lists: Array<{ id: string; name: string }>; + labels: Array<{ id: string; name: string; color: string }>; + customFields: Array<{ id: string; name: string; type: string }>; +} + +interface JiraProjectOption { + key: string; + name: string; +} + +interface JiraProjectDetails { + statuses: Array<{ name: string; id: string }>; + issueTypes: Array<{ name: string; subtask: boolean }>; + fields: Array<{ id: string; name: string; custom: boolean }>; +} + +// ============================================================================ +// Wizard State +// ============================================================================ + +type Provider = 'trello' | 'jira'; + +interface WizardState { + provider: Provider; + // Step 2: Credentials + trelloApiKeyCredentialId: number | null; + trelloTokenCredentialId: number | null; + jiraEmailCredentialId: number | null; + jiraApiTokenCredentialId: number | null; + jiraBaseUrl: string; + verificationResult: { provider: Provider; display: string } | null; + verifyError: string | null; + // Step 3: Board/Project + trelloBoardId: string; + trelloBoards: TrelloBoardOption[]; + jiraProjectKey: string; + jiraProjects: JiraProjectOption[]; + // Step 4: Field mapping + trelloBoardDetails: TrelloBoardDetails | null; + jiraProjectDetails: JiraProjectDetails | null; + // Trello mappings + trelloListMappings: Record; + trelloLabelMappings: Record; + trelloCostFieldId: string; + // JIRA mappings + jiraStatusMappings: Record; + jiraIssueTypes: Record; + jiraLabels: Record; + jiraCostFieldId: string; + // Editing mode + isEditing: boolean; +} + +type WizardAction = + | { type: 'SET_PROVIDER'; provider: Provider } + | { type: 'SET_TRELLO_API_KEY_CRED'; id: number | null } + | { type: 'SET_TRELLO_TOKEN_CRED'; id: number | null } + | { type: 'SET_JIRA_EMAIL_CRED'; id: number | null } + | { type: 'SET_JIRA_API_TOKEN_CRED'; id: number | null } + | { type: 'SET_JIRA_BASE_URL'; url: string } + | { + type: 'SET_VERIFICATION'; + result: { provider: Provider; display: string } | null; + error?: string | null; + } + | { type: 'SET_TRELLO_BOARDS'; boards: TrelloBoardOption[] } + | { type: 'SET_TRELLO_BOARD_ID'; id: string } + | { type: 'SET_JIRA_PROJECTS'; projects: JiraProjectOption[] } + | { type: 'SET_JIRA_PROJECT_KEY'; key: string } + | { type: 'SET_TRELLO_BOARD_DETAILS'; details: TrelloBoardDetails | null } + | { type: 'SET_JIRA_PROJECT_DETAILS'; details: JiraProjectDetails | null } + | { type: 'SET_TRELLO_LIST_MAPPING'; key: string; value: string } + | { type: 'SET_TRELLO_LABEL_MAPPING'; key: string; value: string } + | { type: 'SET_TRELLO_COST_FIELD'; id: string } + | { type: 'SET_JIRA_STATUS_MAPPING'; key: string; value: string } + | { type: 'SET_JIRA_ISSUE_TYPE'; key: string; value: string } + | { type: 'SET_JIRA_LABEL'; key: string; value: string } + | { type: 'SET_JIRA_COST_FIELD'; id: string } + | { type: 'INIT_EDIT'; state: Partial }; + +const INITIAL_JIRA_LABELS: Record = { + processing: 'cascade-processing', + processed: 'cascade-processed', + error: 'cascade-error', + readyToProcess: 'cascade-ready', +}; + +function createInitialState(): WizardState { + return { + provider: 'trello', + trelloApiKeyCredentialId: null, + trelloTokenCredentialId: null, + jiraEmailCredentialId: null, + jiraApiTokenCredentialId: null, + jiraBaseUrl: '', + verificationResult: null, + verifyError: null, + trelloBoardId: '', + trelloBoards: [], + jiraProjectKey: '', + jiraProjects: [], + trelloBoardDetails: null, + jiraProjectDetails: null, + trelloListMappings: {}, + trelloLabelMappings: {}, + trelloCostFieldId: '', + jiraStatusMappings: {}, + jiraIssueTypes: {}, + jiraLabels: { ...INITIAL_JIRA_LABELS }, + jiraCostFieldId: '', + isEditing: false, + }; +} + +const wizardReducer: Reducer = (state, action) => { + switch (action.type) { + case 'SET_PROVIDER': + return { + ...createInitialState(), + provider: action.provider, + }; + case 'SET_TRELLO_API_KEY_CRED': + return { + ...state, + trelloApiKeyCredentialId: action.id, + verificationResult: null, + verifyError: null, + }; + case 'SET_TRELLO_TOKEN_CRED': + return { + ...state, + trelloTokenCredentialId: action.id, + verificationResult: null, + verifyError: null, + }; + case 'SET_JIRA_EMAIL_CRED': + return { + ...state, + jiraEmailCredentialId: action.id, + verificationResult: null, + verifyError: null, + }; + case 'SET_JIRA_API_TOKEN_CRED': + return { + ...state, + jiraApiTokenCredentialId: action.id, + verificationResult: null, + verifyError: null, + }; + case 'SET_JIRA_BASE_URL': + return { ...state, jiraBaseUrl: action.url, verificationResult: null, verifyError: null }; + case 'SET_VERIFICATION': + return { ...state, verificationResult: action.result, verifyError: action.error ?? null }; + case 'SET_TRELLO_BOARDS': + return { ...state, trelloBoards: action.boards }; + case 'SET_TRELLO_BOARD_ID': + return { + ...state, + trelloBoardId: action.id, + trelloBoardDetails: null, + trelloListMappings: {}, + trelloLabelMappings: {}, + trelloCostFieldId: '', + }; + case 'SET_JIRA_PROJECTS': + return { ...state, jiraProjects: action.projects }; + case 'SET_JIRA_PROJECT_KEY': + return { + ...state, + jiraProjectKey: action.key, + jiraProjectDetails: null, + jiraStatusMappings: {}, + jiraIssueTypes: {}, + jiraCostFieldId: '', + }; + case 'SET_TRELLO_BOARD_DETAILS': + return { ...state, trelloBoardDetails: action.details }; + case 'SET_JIRA_PROJECT_DETAILS': + return { ...state, jiraProjectDetails: action.details }; + case 'SET_TRELLO_LIST_MAPPING': + return { + ...state, + trelloListMappings: { ...state.trelloListMappings, [action.key]: action.value }, + }; + case 'SET_TRELLO_LABEL_MAPPING': + return { + ...state, + trelloLabelMappings: { ...state.trelloLabelMappings, [action.key]: action.value }, + }; + case 'SET_TRELLO_COST_FIELD': + return { ...state, trelloCostFieldId: action.id }; + case 'SET_JIRA_STATUS_MAPPING': + return { + ...state, + jiraStatusMappings: { ...state.jiraStatusMappings, [action.key]: action.value }, + }; + case 'SET_JIRA_ISSUE_TYPE': + return { + ...state, + jiraIssueTypes: { ...state.jiraIssueTypes, [action.key]: action.value }, + }; + case 'SET_JIRA_LABEL': + return { + ...state, + jiraLabels: { ...state.jiraLabels, [action.key]: action.value }, + }; + case 'SET_JIRA_COST_FIELD': + return { ...state, jiraCostFieldId: action.id }; + case 'INIT_EDIT': + return { ...state, ...action.state, isEditing: true }; + default: + return state; + } +}; + +// ============================================================================ +// Wizard Step Shell +// ============================================================================ + +const STEP_TITLES = [ + 'Provider', + 'Credentials & Verification', + 'Board / Project Selection', + 'Field Mapping', + 'Webhooks', + 'Save', +] as const; + +function WizardStep({ + stepNumber, + title, + status, + isOpen, + onToggle, + children, +}: { + stepNumber: number; + title: string; + status: 'pending' | 'complete' | 'error' | 'active'; + isOpen: boolean; + onToggle: () => void; + children: React.ReactNode; +}) { + return ( +
+ + {isOpen &&
{children}
} +
+ ); +} + +// ============================================================================ +// Inline Credential Creator +// ============================================================================ + +function InlineCredentialCreator({ + onCreated, +}: { + onCreated: (id: number) => void; +}) { + const [isOpen, setIsOpen] = useState(false); + const [name, setName] = useState(''); + const [envVarKey, setEnvVarKey] = useState(''); + const [value, setValue] = useState(''); + const queryClient = useQueryClient(); + + const createMutation = useMutation({ + mutationFn: async () => { + return trpcClient.credentials.create.mutate({ + name, + envVarKey, + value, + isDefault: false, + }); + }, + onSuccess: async (result) => { + await queryClient.invalidateQueries({ + queryKey: trpc.credentials.list.queryOptions().queryKey, + }); + onCreated((result as { id: number }).id); + setIsOpen(false); + setName(''); + setEnvVarKey(''); + setValue(''); + }, + }); + + if (!isOpen) { + return ( + + ); + } + + return ( +
+
+ setName(e.target.value)} + placeholder="Name (e.g. My Trello Key)" + className="flex-1" + /> + setEnvVarKey(e.target.value.toUpperCase())} + placeholder="ENV_VAR_KEY" + className="flex-1" + /> +
+ setValue(e.target.value)} + placeholder="Secret value" + type="password" + /> +
+ + + {createMutation.isError && ( + + {createMutation.error.message} + + )} +
+
+ ); +} + +// ============================================================================ +// Searchable Select +// ============================================================================ + +function SearchableSelect({ + options, + value, + onChange, + placeholder, + isLoading, + error, + onRetry, +}: { + options: T[]; + value: string; + onChange: (value: string) => void; + placeholder: string; + isLoading?: boolean; + error?: string | null; + onRetry?: () => void; +}) { + const [search, setSearch] = useState(''); + + const filtered = search + ? options.filter( + (o) => + o.value === value || + o.label.toLowerCase().includes(search.toLowerCase()) || + o.value.toLowerCase().includes(search.toLowerCase()) || + o.detail?.toLowerCase().includes(search.toLowerCase()), + ) + : options; + + if (isLoading) { + return ( +
+ Loading... +
+ ); + } + + if (error) { + return ( +
+
+ {error} +
+ {onRetry && ( + + )} +
+ ); + } + + return ( +
+ {options.length > 5 && ( + setSearch(e.target.value)} + placeholder="Filter..." + className="h-8 text-sm" + /> + )} + +
+ ); +} + +// ============================================================================ +// Field Mapping Row +// ============================================================================ + +function FieldMappingRow({ + slotLabel, + options, + value, + onChange, + manualFallback, +}: { + slotLabel: string; + options: Array<{ label: string; value: string }>; + value: string; + onChange: (value: string) => void; + manualFallback?: boolean; +}) { + const [isManual, setIsManual] = useState(false); + + // If the value doesn't match any option, show manual mode + const hasMatch = !value || options.some((o) => o.value === value); + const showManual = isManual || (value && !hasMatch && manualFallback); + + return ( +
+ {slotLabel} + {showManual ? ( +
+ onChange(e.target.value)} + placeholder="Enter ID manually" + className="flex-1" + /> + {manualFallback && ( + + )} +
+ ) : ( +
+ + {manualFallback && ( + + )} +
+ )} +
+ ); +} + +// ============================================================================ +// CASCADE slot key definitions +// ============================================================================ + +const TRELLO_LIST_SLOTS = [ + 'briefing', + 'stories', + 'planning', + 'todo', + 'inProgress', + 'inReview', + 'done', + 'merged', + 'debug', +]; + +const TRELLO_LABEL_SLOTS = ['readyToProcess', 'processing', 'processed', 'error']; + +const JIRA_STATUS_SLOTS = [ + 'briefing', + 'planning', + 'todo', + 'inProgress', + 'inReview', + 'done', + 'merged', +]; + +const JIRA_LABEL_SLOTS = ['processing', 'processed', 'error', 'readyToProcess']; + +// ============================================================================ +// Main PMWizard Component +// ============================================================================ + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: wizard component with provider-specific branching across 6 steps +export function PMWizard({ + projectId, + initialProvider, + initialConfig, + initialCredentials, +}: { + projectId: string; + initialProvider: string; + initialConfig?: Record; + initialCredentials: Map; +}) { + const queryClient = useQueryClient(); + const credentialsQuery = useQuery(trpc.credentials.list.queryOptions()); + const orgCredentials = (credentialsQuery.data ?? []) as CredentialOption[]; + const webhooksQuery = useQuery(trpc.webhooks.list.queryOptions({ projectId })); + + const [state, dispatch] = useReducer(wizardReducer, undefined, createInitialState); + const [openSteps, setOpenSteps] = useState>(new Set([1])); + + // Initialize from existing integration + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: restoring state from two provider config shapes + useEffect(() => { + if (!initialConfig || !initialProvider) return; + + const editState: Partial = { + provider: initialProvider as Provider, + }; + + // Restore credential selections + if (initialProvider === 'trello') { + editState.trelloApiKeyCredentialId = initialCredentials.get('api_key') ?? null; + editState.trelloTokenCredentialId = initialCredentials.get('token') ?? null; + editState.trelloBoardId = (initialConfig.boardId as string) ?? ''; + + const lists = initialConfig.lists as Record | undefined; + if (lists) editState.trelloListMappings = lists; + + const labels = initialConfig.labels as Record | undefined; + if (labels) editState.trelloLabelMappings = labels; + + const cf = initialConfig.customFields as Record | undefined; + editState.trelloCostFieldId = cf?.cost ?? ''; + } else if (initialProvider === 'jira') { + editState.jiraEmailCredentialId = initialCredentials.get('email') ?? null; + editState.jiraApiTokenCredentialId = initialCredentials.get('api_token') ?? null; + editState.jiraBaseUrl = (initialConfig.baseUrl as string) ?? ''; + editState.jiraProjectKey = (initialConfig.projectKey as string) ?? ''; + + const statuses = initialConfig.statuses as Record | undefined; + if (statuses) editState.jiraStatusMappings = statuses; + + const issueTypes = initialConfig.issueTypes as Record | undefined; + if (issueTypes) editState.jiraIssueTypes = issueTypes; + + const labels = initialConfig.labels as Record | undefined; + if (labels) editState.jiraLabels = labels; + + const cf = initialConfig.customFields as Record | undefined; + editState.jiraCostFieldId = cf?.cost ?? ''; + } + + dispatch({ type: 'INIT_EDIT', state: editState }); + // In edit mode, open all steps + setOpenSteps(new Set([1, 2, 3, 4, 5, 6])); + }, [initialConfig, initialProvider, initialCredentials]); + + // Toggle step open/closed + const toggleStep = (step: number) => { + setOpenSteps((prev) => { + const next = new Set(prev); + if (next.has(step)) { + next.delete(step); + } else { + next.add(step); + } + return next; + }); + }; + + const advanceToStep = (step: number) => { + setOpenSteps((prev) => { + const next = new Set(prev); + next.add(step); + return next; + }); + }; + + // ---- Step status calculations ---- + + const step1Complete = !!state.provider; + + const credsReady = + state.provider === 'trello' + ? !!(state.trelloApiKeyCredentialId && state.trelloTokenCredentialId) + : !!(state.jiraEmailCredentialId && state.jiraApiTokenCredentialId && state.jiraBaseUrl); + const step2Complete = credsReady && !!state.verificationResult; + + const step3Complete = + state.provider === 'trello' ? !!state.trelloBoardId : !!state.jiraProjectKey; + + const step4Complete = + state.provider === 'trello' + ? Object.keys(state.trelloListMappings).length > 0 + : Object.keys(state.jiraStatusMappings).length > 0; + + // Step 5 (webhooks) is optional, always "complete" + const step5Complete = true; + + function getStatus( + stepNum: number, + complete: boolean, + ): 'pending' | 'complete' | 'error' | 'active' { + if (complete) return 'complete'; + if (openSteps.has(stepNum)) return 'active'; + return 'pending'; + } + + // ---- Mutations ---- + + const verifyMutation = useMutation({ + mutationFn: async () => { + const provider = state.provider; + if (provider === 'trello') { + if (!state.trelloApiKeyCredentialId || !state.trelloTokenCredentialId) { + throw new Error('Select both credentials before verifying'); + } + const result = await trpcClient.integrationsDiscovery.verifyTrello.mutate({ + apiKeyCredentialId: state.trelloApiKeyCredentialId, + tokenCredentialId: state.trelloTokenCredentialId, + }); + return { provider: 'trello' as const, result }; + } + if (!state.jiraEmailCredentialId || !state.jiraApiTokenCredentialId) { + throw new Error('Select both credentials before verifying'); + } + const result = await trpcClient.integrationsDiscovery.verifyJira.mutate({ + emailCredentialId: state.jiraEmailCredentialId, + apiTokenCredentialId: state.jiraApiTokenCredentialId, + baseUrl: state.jiraBaseUrl, + }); + return { provider: 'jira' as const, result }; + }, + onSuccess: ({ provider, result }) => { + // Ignore if provider changed while we were verifying + if (provider !== state.provider) return; + if (provider === 'trello') { + const r = result as { username: string; fullName: string }; + dispatch({ + type: 'SET_VERIFICATION', + result: { provider: 'trello', display: `@${r.username} (${r.fullName})` }, + }); + } else { + const r = result as { displayName: string; emailAddress: string }; + dispatch({ + type: 'SET_VERIFICATION', + result: { provider: 'jira', display: `${r.displayName} (${r.emailAddress})` }, + }); + } + advanceToStep(3); + }, + onError: (err) => { + dispatch({ + type: 'SET_VERIFICATION', + result: null, + error: err instanceof Error ? err.message : String(err), + }); + }, + }); + + const boardsMutation = useMutation({ + mutationFn: () => { + if (!state.trelloApiKeyCredentialId || !state.trelloTokenCredentialId) { + throw new Error('Select both credentials before fetching boards'); + } + return trpcClient.integrationsDiscovery.trelloBoards.mutate({ + apiKeyCredentialId: state.trelloApiKeyCredentialId, + tokenCredentialId: state.trelloTokenCredentialId, + }); + }, + onSuccess: (boards) => dispatch({ type: 'SET_TRELLO_BOARDS', boards }), + }); + + const boardDetailsMutation = useMutation({ + mutationFn: (boardId: string) => { + if (!state.trelloApiKeyCredentialId || !state.trelloTokenCredentialId) { + throw new Error('Select both credentials before fetching board details'); + } + return trpcClient.integrationsDiscovery.trelloBoardDetails.mutate({ + apiKeyCredentialId: state.trelloApiKeyCredentialId, + tokenCredentialId: state.trelloTokenCredentialId, + boardId, + }); + }, + onSuccess: (details) => { + dispatch({ type: 'SET_TRELLO_BOARD_DETAILS', details }); + advanceToStep(4); + }, + }); + + const jiraProjectsMutation = useMutation({ + mutationFn: () => { + if (!state.jiraEmailCredentialId || !state.jiraApiTokenCredentialId) { + throw new Error('Select both credentials before fetching projects'); + } + return trpcClient.integrationsDiscovery.jiraProjects.mutate({ + emailCredentialId: state.jiraEmailCredentialId, + apiTokenCredentialId: state.jiraApiTokenCredentialId, + baseUrl: state.jiraBaseUrl, + }); + }, + onSuccess: (projects) => dispatch({ type: 'SET_JIRA_PROJECTS', projects }), + }); + + const jiraDetailsMutation = useMutation({ + mutationFn: (projectKey: string) => { + if (!state.jiraEmailCredentialId || !state.jiraApiTokenCredentialId) { + throw new Error('Select both credentials before fetching project details'); + } + return trpcClient.integrationsDiscovery.jiraProjectDetails.mutate({ + emailCredentialId: state.jiraEmailCredentialId, + apiTokenCredentialId: state.jiraApiTokenCredentialId, + baseUrl: state.jiraBaseUrl, + projectKey, + }); + }, + onSuccess: (details) => { + dispatch({ type: 'SET_JIRA_PROJECT_DETAILS', details }); + advanceToStep(4); + }, + }); + + // Fetch boards/projects when step 3 opens and credentials are verified + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger only on verification result change + useEffect(() => { + if (!state.verificationResult) return; + if ( + state.provider === 'trello' && + state.trelloBoards.length === 0 && + !boardsMutation.isPending + ) { + boardsMutation.mutate(); + } else if ( + state.provider === 'jira' && + state.jiraProjects.length === 0 && + !jiraProjectsMutation.isPending + ) { + jiraProjectsMutation.mutate(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.verificationResult]); + + // In edit mode, auto-fetch boards/projects list and details when credentials are present + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger only on edit mode state changes + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: two-provider branching with guard conditions + useEffect(() => { + if (!state.isEditing) return; + + if (state.provider === 'trello') { + if ( + state.trelloApiKeyCredentialId && + state.trelloTokenCredentialId && + state.trelloBoards.length === 0 && + !boardsMutation.isPending + ) { + boardsMutation.mutate(); + } + if ( + state.trelloBoardId && + !state.trelloBoardDetails && + state.trelloApiKeyCredentialId && + state.trelloTokenCredentialId && + !boardDetailsMutation.isPending + ) { + boardDetailsMutation.mutate(state.trelloBoardId); + } + } else if (state.provider === 'jira') { + if ( + state.jiraEmailCredentialId && + state.jiraApiTokenCredentialId && + state.jiraProjects.length === 0 && + !jiraProjectsMutation.isPending + ) { + jiraProjectsMutation.mutate(); + } + if ( + state.jiraProjectKey && + !state.jiraProjectDetails && + state.jiraEmailCredentialId && + state.jiraApiTokenCredentialId && + !jiraDetailsMutation.isPending + ) { + jiraDetailsMutation.mutate(state.jiraProjectKey); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.isEditing, state.trelloBoardId, state.jiraProjectKey]); + + // Fetch board/project details when selection changes + const handleBoardSelect = (boardId: string) => { + dispatch({ type: 'SET_TRELLO_BOARD_ID', id: boardId }); + if (boardId) { + boardDetailsMutation.mutate(boardId); + } + }; + + const handleProjectSelect = (key: string) => { + dispatch({ type: 'SET_JIRA_PROJECT_KEY', key }); + if (key) { + jiraDetailsMutation.mutate(key); + } + }; + + // ---- Webhook management ---- + const [webhookUrl, setWebhookUrl] = useState(() => { + const origin = typeof window !== 'undefined' ? window.location.origin : ''; + // Dev: replace frontend port with backend port + return origin.replace(':5173', ':3000'); + }); + + const createWebhookMutation = useMutation({ + mutationFn: () => + trpcClient.webhooks.create.mutate({ + projectId, + callbackBaseUrl: webhookUrl, + trelloOnly: state.provider === 'trello' ? true : undefined, + jiraOnly: state.provider === 'jira' ? true : undefined, + }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.webhooks.list.queryOptions({ projectId }).queryKey, + }); + }, + }); + + const deleteWebhookMutation = useMutation({ + mutationFn: (callbackBaseUrl: string) => + trpcClient.webhooks.delete.mutate({ + projectId, + callbackBaseUrl, + trelloOnly: state.provider === 'trello' ? true : undefined, + jiraOnly: state.provider === 'jira' ? true : undefined, + }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.webhooks.list.queryOptions({ projectId }).queryKey, + }); + }, + }); + + // ---- Save ---- + + const saveMutation = useMutation({ + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: handles two provider types + credential linking + mutationFn: async () => { + let config: Record; + if (state.provider === 'trello') { + config = { + boardId: state.trelloBoardId, + lists: state.trelloListMappings, + labels: state.trelloLabelMappings, + ...(state.trelloCostFieldId ? { customFields: { cost: state.trelloCostFieldId } } : {}), + }; + } else { + config = { + projectKey: state.jiraProjectKey, + baseUrl: state.jiraBaseUrl, + statuses: state.jiraStatusMappings, + ...(Object.keys(state.jiraIssueTypes).length > 0 + ? { issueTypes: state.jiraIssueTypes } + : {}), + ...(Object.keys(state.jiraLabels).length > 0 ? { labels: state.jiraLabels } : {}), + ...(state.jiraCostFieldId ? { customFields: { cost: state.jiraCostFieldId } } : {}), + }; + } + + const result = await trpcClient.projects.integrations.upsert.mutate({ + projectId, + category: 'pm', + provider: state.provider, + config, + }); + + // Set credentials + const credPairs: Array<{ role: string; credentialId: number }> = + state.provider === 'trello' + ? [ + ...(state.trelloApiKeyCredentialId + ? [{ role: 'api_key', credentialId: state.trelloApiKeyCredentialId }] + : []), + ...(state.trelloTokenCredentialId + ? [{ role: 'token', credentialId: state.trelloTokenCredentialId }] + : []), + ] + : [ + ...(state.jiraEmailCredentialId + ? [{ role: 'email', credentialId: state.jiraEmailCredentialId }] + : []), + ...(state.jiraApiTokenCredentialId + ? [{ role: 'api_token', credentialId: state.jiraApiTokenCredentialId }] + : []), + ]; + + for (const { role, credentialId } of credPairs) { + await trpcClient.projects.integrationCredentials.set.mutate({ + projectId, + category: 'pm', + role, + credentialId, + }); + } + + return result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: trpc.projects.integrationCredentials.list.queryOptions({ + projectId, + category: 'pm', + }).queryKey, + }); + }, + }); + + // ---- Active webhooks for this provider ---- + const activeWebhooks = + state.provider === 'trello' + ? (webhooksQuery.data?.trello ?? []).map((w) => ({ + id: String(w.id), + url: w.callbackURL, + active: w.active, + })) + : (webhooksQuery.data?.jira ?? []).map((w) => ({ + id: String(w.id), + url: w.url, + active: w.enabled, + })); + + // ---- Render ---- + + return ( +
+ {/* Step 1: Provider */} + toggleStep(1)} + > +
+ +
+ {(['trello', 'jira'] as const).map((p) => ( + + ))} +
+
+
+ + {/* Step 2: Credentials & Verification */} + toggleStep(2)} + > + {state.provider === 'trello' ? ( +
+
+ +
+ +
+ dispatch({ type: 'SET_TRELLO_API_KEY_CRED', id })} + /> +
+
+ +
+ +
+ dispatch({ type: 'SET_TRELLO_TOKEN_CRED', id })} + /> +
+
+ ) : ( +
+
+ + dispatch({ type: 'SET_JIRA_BASE_URL', url: e.target.value })} + placeholder="https://your-instance.atlassian.net" + /> +
+
+ + + dispatch({ type: 'SET_JIRA_EMAIL_CRED', id })} + /> +
+
+ + + dispatch({ type: 'SET_JIRA_API_TOKEN_CRED', id })} + /> +
+
+ )} + +
+ + {state.verificationResult && ( +
+ + Connected as {state.verificationResult.display} +
+ )} + {state.verifyError && ( +
+ + {state.verifyError} +
+ )} +
+
+ + {/* Step 3: Board / Project Selection */} + toggleStep(3)} + > + {state.provider === 'trello' ? ( +
+ + ({ + label: b.name, + value: b.id, + detail: b.url.split('/').pop(), + }))} + value={state.trelloBoardId} + onChange={handleBoardSelect} + placeholder="Select a Trello board..." + isLoading={boardsMutation.isPending} + error={boardsMutation.isError ? boardsMutation.error.message : null} + onRetry={() => boardsMutation.mutate()} + /> + {state.trelloBoardId && boardDetailsMutation.isPending && ( +
+ Loading board details... +
+ )} +
+ ) : ( +
+ + ({ + label: p.name, + value: p.key, + detail: p.key, + }))} + value={state.jiraProjectKey} + onChange={handleProjectSelect} + placeholder="Select a JIRA project..." + isLoading={jiraProjectsMutation.isPending} + error={jiraProjectsMutation.isError ? jiraProjectsMutation.error.message : null} + onRetry={() => jiraProjectsMutation.mutate()} + /> + {state.jiraProjectKey && jiraDetailsMutation.isPending && ( +
+ Loading project details... +
+ )} +
+ )} +
+ + {/* Step 4: Field Mapping */} + toggleStep(4)} + > + {state.provider === 'trello' ? ( +
+ {/* List mappings */} +
+ +

+ Map each CASCADE stage to a Trello list on the board. +

+ {state.trelloBoardDetails ? ( + TRELLO_LIST_SLOTS.map((slot) => ( + ({ + label: l.name, + value: l.id, + })) ?? [] + } + value={state.trelloListMappings[slot] ?? ''} + onChange={(v) => + dispatch({ + type: 'SET_TRELLO_LIST_MAPPING', + key: slot, + value: v, + }) + } + manualFallback + /> + )) + ) : ( +

+ Select a board first to populate list options. +

+ )} +
+ + {/* Label mappings */} +
+ +

+ Map each CASCADE label to a Trello label on the board. +

+ {state.trelloBoardDetails ? ( + TRELLO_LABEL_SLOTS.map((slot) => ( + l.name) + .map((l) => ({ + label: `${l.name} (${l.color})`, + value: l.id, + })) ?? [] + } + value={state.trelloLabelMappings[slot] ?? ''} + onChange={(v) => + dispatch({ + type: 'SET_TRELLO_LABEL_MAPPING', + key: slot, + value: v, + }) + } + manualFallback + /> + )) + ) : ( +

+ Select a board first to populate label options. +

+ )} +
+ + {/* Cost custom field */} +
+ + {state.trelloBoardDetails ? ( + f.type === 'number') + .map((f) => ({ + label: f.name, + value: f.id, + }))} + value={state.trelloCostFieldId} + onChange={(v) => dispatch({ type: 'SET_TRELLO_COST_FIELD', id: v })} + manualFallback + /> + ) : ( + + dispatch({ + type: 'SET_TRELLO_COST_FIELD', + id: e.target.value, + }) + } + placeholder="Custom field ID for cost tracking" + /> + )} +
+
+ ) : ( +
+ {/* Status mappings */} +
+ +

+ Map each CASCADE status to a JIRA status in the project. +

+ {state.jiraProjectDetails ? ( + JIRA_STATUS_SLOTS.map((slot) => ( + ({ + label: s.name, + value: s.name, + })) ?? [] + } + value={state.jiraStatusMappings[slot] ?? ''} + onChange={(v) => + dispatch({ + type: 'SET_JIRA_STATUS_MAPPING', + key: slot, + value: v, + }) + } + manualFallback + /> + )) + ) : ( +

+ Select a project first to populate status options. +

+ )} +
+ + {/* Issue types */} +
+ +

+ Map CASCADE issue types. Typically "task" for the main type and + "subtask" for sub-tasks. +

+ {state.jiraProjectDetails ? ( + <> + !t.subtask) + .map((t) => ({ + label: t.name, + value: t.name, + }))} + value={state.jiraIssueTypes.task ?? ''} + onChange={(v) => + dispatch({ + type: 'SET_JIRA_ISSUE_TYPE', + key: 'task', + value: v, + }) + } + manualFallback + /> + t.subtask) + .map((t) => ({ + label: t.name, + value: t.name, + }))} + value={state.jiraIssueTypes.subtask ?? ''} + onChange={(v) => + dispatch({ + type: 'SET_JIRA_ISSUE_TYPE', + key: 'subtask', + value: v, + }) + } + manualFallback + /> + + ) : ( +

Select a project first.

+ )} +
+ + {/* Labels */} +
+ +

+ CASCADE label names used in JIRA. These are created automatically by CASCADE. +

+ {JIRA_LABEL_SLOTS.map((slot) => ( +
+ {slot} + + dispatch({ + type: 'SET_JIRA_LABEL', + key: slot, + value: e.target.value, + }) + } + placeholder={`JIRA label for ${slot}`} + className="flex-1" + /> +
+ ))} +
+ + {/* Cost custom field */} +
+ + {state.jiraProjectDetails ? ( + ({ + label: `${f.name} (${f.id})`, + value: f.id, + }))} + value={state.jiraCostFieldId} + onChange={(v) => dispatch({ type: 'SET_JIRA_COST_FIELD', id: v })} + manualFallback + /> + ) : ( + + dispatch({ + type: 'SET_JIRA_COST_FIELD', + id: e.target.value, + }) + } + placeholder="e.g., customfield_10042" + /> + )} +
+
+ )} +
+ + {/* Step 5: Webhooks */} + toggleStep(5)} + > +
+ {webhooksQuery.isLoading ? ( +
+ Loading webhooks... +
+ ) : activeWebhooks.length > 0 ? ( +
+ + {activeWebhooks.map((w) => ( +
+
+ + {w.url} +
+ +
+ ))} +
+ ) : ( +
+ + No {state.provider === 'trello' ? 'Trello' : 'JIRA'} webhooks configured for this + project. +
+ )} + +
+ +

+ The base URL where CASCADE receives webhooks. The{' '} + {state.provider === 'trello' ? '/trello/webhook' : '/jira/webhook'} path is appended + automatically. +

+
+ setWebhookUrl(e.target.value)} + placeholder="https://cascade.example.com" + /> + +
+ {createWebhookMutation.isError && ( +

{createWebhookMutation.error.message}

+ )} + {createWebhookMutation.isSuccess && ( +

Webhook created successfully.

+ )} +
+
+
+ + {/* Step 6: Save */} + toggleStep(6)} + > +
+ {/* Summary */} +
+
+ Provider + {state.provider === 'trello' ? 'Trello' : 'JIRA'} +
+ {state.verificationResult && ( +
+ Identity + {state.verificationResult.display} +
+ )} +
+ + {state.provider === 'trello' ? 'Board' : 'Project'} + + + {state.provider === 'trello' + ? state.trelloBoards.find((b) => b.id === state.trelloBoardId)?.name || + state.trelloBoardId + : state.jiraProjects.find((p) => p.key === state.jiraProjectKey)?.name || + state.jiraProjectKey} + +
+
+ + {state.provider === 'trello' ? 'Lists mapped' : 'Statuses mapped'} + + + {state.provider === 'trello' + ? Object.keys(state.trelloListMappings).filter((k) => state.trelloListMappings[k]) + .length + : Object.keys(state.jiraStatusMappings).filter((k) => state.jiraStatusMappings[k]) + .length} + +
+
+ +

+ Trigger configuration is managed separately in the Agent Configs tab. +

+ +
+ + {saveMutation.isSuccess && ( + Integration saved successfully. + )} + {saveMutation.isError && ( + {saveMutation.error.message} + )} +
+
+
+
+ ); +}