From 11d3f800569ba0b705f512fb13744140f1774710 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Tue, 24 Feb 2026 09:49:58 +0000 Subject: [PATCH] refactor(webhooks): extract platform adapters and shared org-access guard --- src/api/routers/_shared/orgAccess.ts | 33 ++ src/api/routers/agentConfigs.ts | 37 +- src/api/routers/runs.ts | 44 +- src/api/routers/webhooks.ts | 464 ++---------------- src/api/routers/webhooks/github.ts | 79 +++ src/api/routers/webhooks/jira.ts | 208 ++++++++ src/api/routers/webhooks/trello.ts | 94 ++++ src/api/routers/webhooks/types.ts | 84 ++++ .../api/routers/_shared/orgAccess.test.ts | 103 ++++ .../api/routers/webhooks/adapters.test.ts | 462 +++++++++++++++++ 10 files changed, 1124 insertions(+), 484 deletions(-) create mode 100644 src/api/routers/_shared/orgAccess.ts create mode 100644 src/api/routers/webhooks/github.ts create mode 100644 src/api/routers/webhooks/jira.ts create mode 100644 src/api/routers/webhooks/trello.ts create mode 100644 src/api/routers/webhooks/types.ts create mode 100644 tests/unit/api/routers/_shared/orgAccess.test.ts create mode 100644 tests/unit/api/routers/webhooks/adapters.test.ts diff --git a/src/api/routers/_shared/orgAccess.ts b/src/api/routers/_shared/orgAccess.ts new file mode 100644 index 00000000..7c8ee3a7 --- /dev/null +++ b/src/api/routers/_shared/orgAccess.ts @@ -0,0 +1,33 @@ +import { TRPCError } from '@trpc/server'; +import { eq } from 'drizzle-orm'; +import { getDb } from '../../../db/client.js'; +import { getRunById } from '../../../db/repositories/runsRepository.js'; +import { projects } from '../../../db/schema/index.js'; + +/** + * Verify that a project belongs to the given org. + * Throws `NOT_FOUND` if the project does not exist or belongs to a different org. + */ +export async function verifyProjectOrgAccess(projectId: string, orgId: string): Promise { + const db = getDb(); + const [project] = await db + .select({ orgId: projects.orgId }) + .from(projects) + .where(eq(projects.id, projectId)); + if (!project || project.orgId !== orgId) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } +} + +/** + * Verify that a run belongs to the given org (via its associated project). + * Throws `NOT_FOUND` if the run does not exist or belongs to a different org. + * Runs without a projectId are allowed through (no org scoping needed). + */ +export async function verifyRunOrgAccess(runId: string, orgId: string): Promise { + const run = await getRunById(runId); + if (!run) throw new TRPCError({ code: 'NOT_FOUND' }); + if (run.projectId) { + await verifyProjectOrgAccess(run.projectId, orgId); + } +} diff --git a/src/api/routers/agentConfigs.ts b/src/api/routers/agentConfigs.ts index 31d26fe4..b50518cc 100644 --- a/src/api/routers/agentConfigs.ts +++ b/src/api/routers/agentConfigs.ts @@ -11,8 +11,9 @@ import { listAgentConfigs, updateAgentConfig, } from '../../db/repositories/settingsRepository.js'; -import { agentConfigs, projects } from '../../db/schema/index.js'; +import { agentConfigs } from '../../db/schema/index.js'; import { protectedProcedure, publicProcedure, router } from '../trpc.js'; +import { verifyProjectOrgAccess } from './_shared/orgAccess.js'; async function validatePromptIfPresent(prompt: string | null | undefined) { if (!prompt) return; @@ -36,14 +37,7 @@ export const agentConfigsRouter = router({ .query(async ({ ctx, input }) => { if (input?.projectId) { // Verify project belongs to org - const db = getDb(); - const [project] = await db - .select({ orgId: projects.orgId }) - .from(projects) - .where(eq(projects.id, input.projectId)); - if (!project || project.orgId !== ctx.effectiveOrgId) { - throw new TRPCError({ code: 'NOT_FOUND' }); - } + await verifyProjectOrgAccess(input.projectId, ctx.effectiveOrgId); return listAgentConfigs({ projectId: input.projectId }); } return listAgentConfigs({ orgId: ctx.effectiveOrgId }); @@ -64,14 +58,7 @@ export const agentConfigsRouter = router({ .mutation(async ({ ctx, input }) => { // If projectId given, verify ownership if (input.projectId) { - const db = getDb(); - const [project] = await db - .select({ orgId: projects.orgId }) - .from(projects) - .where(eq(projects.id, input.projectId)); - if (!project || project.orgId !== ctx.effectiveOrgId) { - throw new TRPCError({ code: 'NOT_FOUND' }); - } + await verifyProjectOrgAccess(input.projectId, ctx.effectiveOrgId); } await validatePromptIfPresent(input.prompt); return createAgentConfig({ @@ -112,13 +99,7 @@ export const agentConfigsRouter = router({ } // Check project-scoped configs belong to user's org if (config.projectId) { - const [project] = await db - .select({ orgId: projects.orgId }) - .from(projects) - .where(eq(projects.id, config.projectId)); - if (!project || project.orgId !== ctx.effectiveOrgId) { - throw new TRPCError({ code: 'NOT_FOUND' }); - } + await verifyProjectOrgAccess(config.projectId, ctx.effectiveOrgId); } const { id, ...updates } = input; @@ -141,13 +122,7 @@ export const agentConfigsRouter = router({ throw new TRPCError({ code: 'NOT_FOUND' }); } if (config.projectId) { - const [project] = await db - .select({ orgId: projects.orgId }) - .from(projects) - .where(eq(projects.id, config.projectId)); - if (!project || project.orgId !== ctx.effectiveOrgId) { - throw new TRPCError({ code: 'NOT_FOUND' }); - } + await verifyProjectOrgAccess(config.projectId, ctx.effectiveOrgId); } await deleteAgentConfig(input.id); diff --git a/src/api/routers/runs.ts b/src/api/routers/runs.ts index 2c1780ae..a3871515 100644 --- a/src/api/routers/runs.ts +++ b/src/api/routers/runs.ts @@ -1,8 +1,6 @@ import { TRPCError } from '@trpc/server'; -import { eq } from 'drizzle-orm'; import { z } from 'zod'; import { loadProjectConfigById } from '../../config/provider.js'; -import { getDb } from '../../db/client.js'; import { deleteDebugAnalysisByRunId, getDebugAnalysisByRunId, @@ -12,10 +10,10 @@ import { listLlmCallsMeta, listRuns, } from '../../db/repositories/runsRepository.js'; -import { projects } from '../../db/schema/index.js'; import { isAnalysisRunning } from '../../triggers/shared/debug-status.js'; import { logger } from '../../utils/logging.js'; import { protectedProcedure, router } from '../trpc.js'; +import { verifyProjectOrgAccess } from './_shared/orgAccess.js'; const useQueue = !!process.env.REDIS_URL; @@ -57,14 +55,7 @@ export const runsRouter = router({ // Verify org access if (run.projectId) { - const db = getDb(); - const [project] = await db - .select({ orgId: projects.orgId }) - .from(projects) - .where(eq(projects.id, run.projectId)); - if (!project || project.orgId !== ctx.effectiveOrgId) { - throw new TRPCError({ code: 'NOT_FOUND' }); - } + await verifyProjectOrgAccess(run.projectId, ctx.effectiveOrgId); } return run; @@ -119,14 +110,7 @@ export const runsRouter = router({ // Verify org access if (run.projectId) { - const db = getDb(); - const [project] = await db - .select({ orgId: projects.orgId }) - .from(projects) - .where(eq(projects.id, run.projectId)); - if (!project || project.orgId !== ctx.effectiveOrgId) { - throw new TRPCError({ code: 'NOT_FOUND' }); - } + await verifyProjectOrgAccess(run.projectId, ctx.effectiveOrgId); } if (run.agentType === 'debug') { @@ -199,18 +183,7 @@ export const runsRouter = router({ ) .mutation(async ({ ctx, input }) => { // Verify org ownership of project - const db = getDb(); - const [project] = await db - .select({ orgId: projects.orgId }) - .from(projects) - .where(eq(projects.id, input.projectId)); - - if (!project || project.orgId !== ctx.effectiveOrgId) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'Project not found', - }); - } + await verifyProjectOrgAccess(input.projectId, ctx.effectiveOrgId); const pc = await loadProjectConfigById(input.projectId); if (!pc) { @@ -273,14 +246,7 @@ export const runsRouter = router({ // Verify org access if (run.projectId) { - const db = getDb(); - const [project] = await db - .select({ orgId: projects.orgId }) - .from(projects) - .where(eq(projects.id, run.projectId)); - if (!project || project.orgId !== ctx.effectiveOrgId) { - throw new TRPCError({ code: 'NOT_FOUND' }); - } + await verifyProjectOrgAccess(run.projectId, ctx.effectiveOrgId); } if (!run.projectId) { diff --git a/src/api/routers/webhooks.ts b/src/api/routers/webhooks.ts index 1767ebd5..920436eb 100644 --- a/src/api/routers/webhooks.ts +++ b/src/api/routers/webhooks.ts @@ -1,75 +1,36 @@ -import { Octokit } from '@octokit/rest'; import { TRPCError } from '@trpc/server'; -import { eq } from 'drizzle-orm'; import { z } from 'zod'; import { getAllProjectCredentials } from '../../config/provider.js'; -import { getDb } from '../../db/client.js'; import { findProjectByIdFromDb } from '../../db/repositories/configRepository.js'; -import { projects } from '../../db/schema/index.js'; import { getJiraConfig, getTrelloConfig } from '../../pm/config.js'; -import { parseRepoFullName } from '../../utils/repo.js'; import { protectedProcedure, router } from '../trpc.js'; +import { verifyProjectOrgAccess } from './_shared/orgAccess.js'; +import { GitHubWebhookAdapter } from './webhooks/github.js'; +import { JiraWebhookAdapter, jiraEnsureLabels } from './webhooks/jira.js'; +import { TrelloWebhookAdapter } from './webhooks/trello.js'; +import type { + GitHubWebhook, + JiraWebhookInfo, + ProjectContext, + TrelloWebhook, +} from './webhooks/types.js'; -const GITHUB_WEBHOOK_EVENTS = [ - 'pull_request', - 'pull_request_review', - 'check_suite', - 'issue_comment', -]; +// Re-export webhook types for CLI and other consumers +export type { TrelloWebhook, GitHubWebhook, JiraWebhookInfo } from './webhooks/types.js'; -export interface TrelloWebhook { - id: string; - description: string; - idModel: string; - callbackURL: string; - active: boolean; -} +// --- Adapter instances --- -export interface GitHubWebhook { - id: number; - name: string; - active: boolean; - events: string[]; - config: { url?: string; content_type?: string }; -} +const trelloAdapter = new TrelloWebhookAdapter(); +const githubAdapter = new GitHubWebhookAdapter(); +const jiraAdapter = new JiraWebhookAdapter(); -export interface JiraWebhookInfo { - id: number; - name: string; - url: string; - events: string[]; - enabled: boolean; -} - -interface ProjectContext { - projectId: string; - orgId: string; - repo: string; - pmType: 'trello' | 'jira'; - boardId?: string; - jiraBaseUrl?: string; - jiraProjectKey?: string; - jiraLabels?: string[]; - trelloApiKey: string; - trelloToken: string; - githubToken: string; - jiraEmail?: string; - jiraApiToken?: string; -} +// --- Project context resolution --- async function resolveProjectContext( projectId: string, userOrgId: string, ): Promise { - // Verify ownership - const db = getDb(); - const [proj] = await db - .select({ orgId: projects.orgId }) - .from(projects) - .where(eq(projects.id, projectId)); - if (!proj || proj.orgId !== userOrgId) { - throw new TRPCError({ code: 'NOT_FOUND' }); - } + await verifyProjectOrgAccess(projectId, userOrgId); const project = await findProjectByIdFromDb(projectId); if (!project) { @@ -107,261 +68,6 @@ async function resolveProjectContext( }; } -// --- Trello helpers --- - -async function trelloListWebhooks(ctx: ProjectContext): Promise { - if (!ctx.trelloApiKey || !ctx.trelloToken || !ctx.boardId) return []; - const response = await fetch( - `https://api.trello.com/1/tokens/${ctx.trelloToken}/webhooks?key=${ctx.trelloApiKey}`, - ); - if (!response.ok) { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: `Failed to list Trello webhooks: ${response.status}`, - }); - } - const webhooks = (await response.json()) as TrelloWebhook[]; - return webhooks.filter((w) => w.idModel === ctx.boardId); -} - -async function trelloCreateWebhook( - ctx: ProjectContext, - callbackURL: string, -): Promise { - const response = await fetch( - `https://api.trello.com/1/webhooks/?key=${ctx.trelloApiKey}&token=${ctx.trelloToken}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - callbackURL, - idModel: ctx.boardId, - description: `CASCADE webhook for project ${ctx.projectId}`, - }), - }, - ); - if (!response.ok) { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: `Failed to create Trello webhook: ${response.status}`, - }); - } - return (await response.json()) as TrelloWebhook; -} - -async function trelloDeleteWebhook(ctx: ProjectContext, webhookId: string): Promise { - const response = await fetch( - `https://api.trello.com/1/webhooks/${webhookId}?key=${ctx.trelloApiKey}&token=${ctx.trelloToken}`, - { method: 'DELETE' }, - ); - if (!response.ok) { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: `Failed to delete Trello webhook ${webhookId}: ${response.status}`, - }); - } -} - -// --- JIRA helpers --- - -function jiraAuthHeader(ctx: ProjectContext): string { - return `Basic ${Buffer.from(`${ctx.jiraEmail}:${ctx.jiraApiToken}`).toString('base64')}`; -} - -async function jiraListWebhooks(ctx: ProjectContext): Promise { - if (!ctx.jiraBaseUrl || !ctx.jiraEmail || !ctx.jiraApiToken) return []; - const response = await fetch(`${ctx.jiraBaseUrl}/rest/api/3/webhook`, { - headers: { - Authorization: jiraAuthHeader(ctx), - Accept: 'application/json', - }, - }); - if (!response.ok) { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: `Failed to list JIRA webhooks: ${response.status}`, - }); - } - const data = (await response.json()) as { values?: JiraWebhookInfo[] }; - return data.values ?? []; -} - -async function jiraCreateWebhook( - ctx: ProjectContext, - callbackURL: string, -): Promise { - if (!ctx.jiraBaseUrl || !ctx.jiraEmail || !ctx.jiraApiToken) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'JIRA credentials not configured', - }); - } - const response = await fetch(`${ctx.jiraBaseUrl}/rest/api/3/webhook`, { - method: 'POST', - headers: { - Authorization: jiraAuthHeader(ctx), - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify({ - url: callbackURL, - webhooks: [ - { - jqlFilter: '*', - events: [ - 'jira:issue_created', - 'jira:issue_updated', - 'comment_created', - 'comment_updated', - ], - }, - ], - }), - }); - if (!response.ok) { - const errorText = await response.text().catch(() => ''); - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: `Failed to create JIRA webhook: ${response.status} ${errorText}`, - }); - } - return (await response.json()) as JiraWebhookInfo; -} - -async function jiraDeleteWebhook(ctx: ProjectContext, webhookId: number): Promise { - if (!ctx.jiraBaseUrl || !ctx.jiraEmail || !ctx.jiraApiToken) return; - const response = await fetch(`${ctx.jiraBaseUrl}/rest/api/3/webhook`, { - method: 'DELETE', - headers: { - Authorization: jiraAuthHeader(ctx), - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify({ webhookIds: [webhookId] }), - }); - if (!response.ok) { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: `Failed to delete JIRA webhook ${webhookId}: ${response.status}`, - }); - } -} - -// --- GitHub helpers --- - -async function githubListWebhooks(ctx: ProjectContext): Promise { - if (!ctx.githubToken) return []; - const octokit = new Octokit({ auth: ctx.githubToken }); - const { owner, repo } = parseRepoFullName(ctx.repo); - const { data } = await octokit.repos.listWebhooks({ owner, repo }); - return data as GitHubWebhook[]; -} - -async function githubCreateWebhook( - ctx: ProjectContext, - callbackURL: string, -): Promise { - const octokit = new Octokit({ auth: ctx.githubToken }); - const { owner, repo } = parseRepoFullName(ctx.repo); - const { data } = await octokit.repos.createWebhook({ - owner, - repo, - config: { url: callbackURL, content_type: 'json' }, - events: GITHUB_WEBHOOK_EVENTS, - active: true, - }); - return data as GitHubWebhook; -} - -async function githubDeleteWebhook(ctx: ProjectContext, hookId: number): Promise { - const octokit = new Octokit({ auth: ctx.githubToken }); - const { owner, repo } = parseRepoFullName(ctx.repo); - await octokit.repos.deleteWebhook({ owner, repo, hook_id: hookId }); -} - -// --- JIRA label seeding --- - -/** - * Ensure CASCADE labels exist in JIRA's autocomplete by briefly adding them to - * an issue and immediately removing them. JIRA auto-creates labels when first - * used, but they won't appear in autocomplete until then. - * - * Returns the list of labels that were seeded, or an empty array if the project - * has no issues yet. - */ -async function jiraEnsureLabels(ctx: ProjectContext): Promise { - if (!ctx.jiraBaseUrl || !ctx.jiraEmail || !ctx.jiraApiToken || !ctx.jiraProjectKey) { - return []; - } - - const labelsToSeed = ctx.jiraLabels ?? []; - if (labelsToSeed.length === 0) return []; - - const auth = jiraAuthHeader(ctx); - - // Find one issue in the project - const searchResponse = await fetch( - `${ctx.jiraBaseUrl}/rest/api/3/search?jql=${encodeURIComponent(`project = "${ctx.jiraProjectKey}" ORDER BY created DESC`)}&maxResults=1&fields=labels`, - { - headers: { Authorization: auth, Accept: 'application/json' }, - }, - ); - - if (!searchResponse.ok) return []; - - const searchData = (await searchResponse.json()) as { - issues?: Array<{ key: string; fields?: { labels?: string[] } }>; - }; - - const issue = searchData.issues?.[0]; - if (!issue) { - // No issues in the project yet — labels will be created when first agent runs - return []; - } - - const existingLabels = issue.fields?.labels ?? []; - const newLabels = labelsToSeed.filter((l) => !existingLabels.includes(l)); - - if (newLabels.length === 0) { - // All labels already exist in the project - return labelsToSeed; - } - - // Add all CASCADE labels to the issue - const addResponse = await fetch(`${ctx.jiraBaseUrl}/rest/api/3/issue/${issue.key}`, { - method: 'PUT', - headers: { - Authorization: auth, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify({ - fields: { - labels: [...existingLabels, ...newLabels], - }, - }), - }); - - if (!addResponse.ok) return []; - - // Immediately restore original labels - await fetch(`${ctx.jiraBaseUrl}/rest/api/3/issue/${issue.key}`, { - method: 'PUT', - headers: { - Authorization: auth, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify({ - fields: { - labels: existingLabels, - }, - }), - }); - - return labelsToSeed; -} - // --- One-time token schema (shared by list/create/delete) --- const oneTimeTokensSchema = z @@ -401,9 +107,9 @@ export const webhooksRouter = router({ applyOneTimeTokens(pctx, input.oneTimeTokens); const [trelloResult, githubResult, jiraResult] = await Promise.allSettled([ - trelloListWebhooks(pctx), - githubListWebhooks(pctx), - jiraListWebhooks(pctx), + trelloAdapter.list(pctx), + githubAdapter.list(pctx), + jiraAdapter.list(pctx), ]); return { @@ -433,6 +139,7 @@ export const webhooksRouter = router({ const pctx = await resolveProjectContext(input.projectId, ctx.effectiveOrgId); applyOneTimeTokens(pctx, input.oneTimeTokens); const baseUrl = input.callbackBaseUrl.replace(/\/$/, ''); + const results: { trello?: TrelloWebhook | string; github?: GitHubWebhook | string; @@ -440,65 +147,23 @@ export const webhooksRouter = router({ labelsEnsured?: string[]; } = {}; - // Trello webhook (skip for JIRA-only projects) - if ( - !input.githubOnly && - !input.jiraOnly && - pctx.trelloApiKey && - pctx.trelloToken && - pctx.boardId - ) { - const trelloCallbackUrl = `${baseUrl}/trello/webhook`; - const existing = await trelloListWebhooks(pctx); - const duplicate = existing.find( - (w) => - w.callbackURL === trelloCallbackUrl || w.callbackURL === `${baseUrl}/webhook/trello`, - ); - - if (duplicate) { - results.trello = `Already exists: ${duplicate.id}`; - } else { - results.trello = await trelloCreateWebhook(pctx, trelloCallbackUrl); - } + // Trello webhook (skip for JIRA-only or GitHub-only) + if (!input.githubOnly && !input.jiraOnly) { + results.trello = await trelloAdapter.create(pctx, baseUrl); } - // JIRA webhook (skip for Trello-only projects) - if ( - !input.trelloOnly && - !input.githubOnly && - pctx.jiraEmail && - pctx.jiraApiToken && - pctx.jiraBaseUrl - ) { - const jiraCallbackUrl = `${baseUrl}/jira/webhook`; - const existing = await jiraListWebhooks(pctx); - const duplicate = existing.find( - (w) => w.url === jiraCallbackUrl || w.url === `${baseUrl}/webhook/jira`, - ); - - if (duplicate) { - results.jira = `Already exists: ${duplicate.id}`; - } else { - results.jira = await jiraCreateWebhook(pctx, jiraCallbackUrl); + // JIRA webhook (skip for Trello-only or GitHub-only) + if (!input.trelloOnly && !input.githubOnly) { + results.jira = await jiraAdapter.create(pctx, baseUrl); + if (results.jira !== undefined) { + // Seed CASCADE labels in JIRA autocomplete whenever JIRA is processed + results.labelsEnsured = await jiraEnsureLabels(pctx); } - - // Seed CASCADE labels in JIRA autocomplete - results.labelsEnsured = await jiraEnsureLabels(pctx); } - // GitHub webhook - if (!input.trelloOnly && !input.jiraOnly && pctx.githubToken) { - const githubCallbackUrl = `${baseUrl}/github/webhook`; - const existing = await githubListWebhooks(pctx); - const duplicate = existing.find( - (w) => w.config.url === githubCallbackUrl || w.config.url === `${baseUrl}/webhook/github`, - ); - - if (duplicate) { - results.github = `Already exists: ${duplicate.id}`; - } else { - results.github = await githubCreateWebhook(pctx, githubCallbackUrl); - } + // GitHub webhook (skip for Trello-only or JIRA-only) + if (!input.trelloOnly && !input.jiraOnly) { + results.github = await githubAdapter.create(pctx, baseUrl); } return results; @@ -519,52 +184,23 @@ export const webhooksRouter = router({ const pctx = await resolveProjectContext(input.projectId, ctx.effectiveOrgId); applyOneTimeTokens(pctx, input.oneTimeTokens); const baseUrl = input.callbackBaseUrl.replace(/\/$/, ''); - const deleted: { trello: string[]; github: number[]; jira: number[] } = { - trello: [], - github: [], - jira: [], - }; - - // Trello - if (!input.githubOnly && !input.jiraOnly && pctx.trelloApiKey && pctx.trelloToken) { - const trelloCallbackUrl = `${baseUrl}/trello/webhook`; - const existing = await trelloListWebhooks(pctx); - const matching = existing.filter( - (w) => - w.callbackURL === trelloCallbackUrl || w.callbackURL === `${baseUrl}/webhook/trello`, - ); - for (const w of matching) { - await trelloDeleteWebhook(pctx, w.id); - deleted.trello.push(w.id); - } - } - - // JIRA - if (!input.trelloOnly && !input.githubOnly && pctx.jiraEmail && pctx.jiraApiToken) { - const jiraCallbackUrl = `${baseUrl}/jira/webhook`; - const existing = await jiraListWebhooks(pctx); - const matching = existing.filter( - (w) => w.url === jiraCallbackUrl || w.url === `${baseUrl}/webhook/jira`, - ); - for (const w of matching) { - await jiraDeleteWebhook(pctx, w.id); - deleted.jira.push(w.id); - } - } - // GitHub - if (!input.trelloOnly && !input.jiraOnly && pctx.githubToken) { - const githubCallbackUrl = `${baseUrl}/github/webhook`; - const existing = await githubListWebhooks(pctx); - const matching = existing.filter( - (w) => w.config.url === githubCallbackUrl || w.config.url === `${baseUrl}/webhook/github`, - ); - for (const w of matching) { - await githubDeleteWebhook(pctx, w.id); - deleted.github.push(w.id); - } - } + const [trelloDeleted, jiraDeleted, githubDeleted] = await Promise.all([ + !input.githubOnly && !input.jiraOnly + ? trelloAdapter.delete(pctx, baseUrl) + : Promise.resolve([]), + !input.trelloOnly && !input.githubOnly + ? jiraAdapter.delete(pctx, baseUrl) + : Promise.resolve([]), + !input.trelloOnly && !input.jiraOnly + ? githubAdapter.delete(pctx, baseUrl) + : Promise.resolve([]), + ]); - return deleted; + return { + trello: trelloDeleted as string[], + github: githubDeleted as number[], + jira: jiraDeleted as number[], + }; }), }); diff --git a/src/api/routers/webhooks/github.ts b/src/api/routers/webhooks/github.ts new file mode 100644 index 00000000..10a7705f --- /dev/null +++ b/src/api/routers/webhooks/github.ts @@ -0,0 +1,79 @@ +import { Octokit } from '@octokit/rest'; +import { parseRepoFullName } from '../../../utils/repo.js'; +import type { GitHubWebhook, ProjectContext, WebhookPlatformAdapter } from './types.js'; + +const GITHUB_WEBHOOK_EVENTS = [ + 'pull_request', + 'pull_request_review', + 'check_suite', + 'issue_comment', +]; + +async function githubListWebhooks(ctx: ProjectContext): Promise { + if (!ctx.githubToken) return []; + const octokit = new Octokit({ auth: ctx.githubToken }); + const { owner, repo } = parseRepoFullName(ctx.repo); + const { data } = await octokit.repos.listWebhooks({ owner, repo }); + return data as GitHubWebhook[]; +} + +async function githubCreateWebhook( + ctx: ProjectContext, + callbackURL: string, +): Promise { + const octokit = new Octokit({ auth: ctx.githubToken }); + const { owner, repo } = parseRepoFullName(ctx.repo); + const { data } = await octokit.repos.createWebhook({ + owner, + repo, + config: { url: callbackURL, content_type: 'json' }, + events: GITHUB_WEBHOOK_EVENTS, + active: true, + }); + return data as GitHubWebhook; +} + +async function githubDeleteWebhook(ctx: ProjectContext, hookId: number): Promise { + const octokit = new Octokit({ auth: ctx.githubToken }); + const { owner, repo } = parseRepoFullName(ctx.repo); + await octokit.repos.deleteWebhook({ owner, repo, hook_id: hookId }); +} + +export class GitHubWebhookAdapter implements WebhookPlatformAdapter { + readonly type = 'github' as const; + + async list(ctx: ProjectContext): Promise { + return githubListWebhooks(ctx); + } + + async create(ctx: ProjectContext, baseUrl: string): Promise { + if (!ctx.githubToken) return undefined; + + const callbackUrl = `${baseUrl}/github/webhook`; + const existing = await githubListWebhooks(ctx); + const duplicate = existing.find( + (w) => w.config.url === callbackUrl || w.config.url === `${baseUrl}/webhook/github`, + ); + + if (duplicate) { + return `Already exists: ${duplicate.id}`; + } + return githubCreateWebhook(ctx, callbackUrl); + } + + async delete(ctx: ProjectContext, baseUrl: string): Promise { + if (!ctx.githubToken) return []; + + const callbackUrl = `${baseUrl}/github/webhook`; + const existing = await githubListWebhooks(ctx); + const matching = existing.filter( + (w) => w.config.url === callbackUrl || w.config.url === `${baseUrl}/webhook/github`, + ); + const deleted: number[] = []; + for (const w of matching) { + await githubDeleteWebhook(ctx, w.id); + deleted.push(w.id); + } + return deleted; + } +} diff --git a/src/api/routers/webhooks/jira.ts b/src/api/routers/webhooks/jira.ts new file mode 100644 index 00000000..39386da2 --- /dev/null +++ b/src/api/routers/webhooks/jira.ts @@ -0,0 +1,208 @@ +import { TRPCError } from '@trpc/server'; +import type { JiraWebhookInfo, ProjectContext, WebhookPlatformAdapter } from './types.js'; + +function jiraAuthHeader(ctx: ProjectContext): string { + return `Basic ${Buffer.from(`${ctx.jiraEmail}:${ctx.jiraApiToken}`).toString('base64')}`; +} + +async function jiraListWebhooks(ctx: ProjectContext): Promise { + if (!ctx.jiraBaseUrl || !ctx.jiraEmail || !ctx.jiraApiToken) return []; + const response = await fetch(`${ctx.jiraBaseUrl}/rest/api/3/webhook`, { + headers: { + Authorization: jiraAuthHeader(ctx), + Accept: 'application/json', + }, + }); + if (!response.ok) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to list JIRA webhooks: ${response.status}`, + }); + } + const data = (await response.json()) as { values?: JiraWebhookInfo[] }; + return data.values ?? []; +} + +async function jiraCreateWebhook( + ctx: ProjectContext, + callbackURL: string, +): Promise { + if (!ctx.jiraBaseUrl || !ctx.jiraEmail || !ctx.jiraApiToken) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'JIRA credentials not configured', + }); + } + const response = await fetch(`${ctx.jiraBaseUrl}/rest/api/3/webhook`, { + method: 'POST', + headers: { + Authorization: jiraAuthHeader(ctx), + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + url: callbackURL, + webhooks: [ + { + jqlFilter: '*', + events: [ + 'jira:issue_created', + 'jira:issue_updated', + 'comment_created', + 'comment_updated', + ], + }, + ], + }), + }); + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to create JIRA webhook: ${response.status} ${errorText}`, + }); + } + return (await response.json()) as JiraWebhookInfo; +} + +async function jiraDeleteWebhook(ctx: ProjectContext, webhookId: number): Promise { + if (!ctx.jiraBaseUrl || !ctx.jiraEmail || !ctx.jiraApiToken) return; + const response = await fetch(`${ctx.jiraBaseUrl}/rest/api/3/webhook`, { + method: 'DELETE', + headers: { + Authorization: jiraAuthHeader(ctx), + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ webhookIds: [webhookId] }), + }); + if (!response.ok) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to delete JIRA webhook ${webhookId}: ${response.status}`, + }); + } +} + +/** + * Ensure CASCADE labels exist in JIRA's autocomplete by briefly adding them to + * an issue and immediately removing them. JIRA auto-creates labels when first + * used, but they won't appear in autocomplete until then. + * + * Returns the list of labels that were seeded, or an empty array if the project + * has no issues yet. + */ +export async function jiraEnsureLabels(ctx: ProjectContext): Promise { + if (!ctx.jiraBaseUrl || !ctx.jiraEmail || !ctx.jiraApiToken || !ctx.jiraProjectKey) { + return []; + } + + const labelsToSeed = ctx.jiraLabels ?? []; + if (labelsToSeed.length === 0) return []; + + const auth = jiraAuthHeader(ctx); + + // Find one issue in the project + const searchResponse = await fetch( + `${ctx.jiraBaseUrl}/rest/api/3/search?jql=${encodeURIComponent(`project = "${ctx.jiraProjectKey}" ORDER BY created DESC`)}&maxResults=1&fields=labels`, + { + headers: { Authorization: auth, Accept: 'application/json' }, + }, + ); + + if (!searchResponse.ok) return []; + + const searchData = (await searchResponse.json()) as { + issues?: Array<{ key: string; fields?: { labels?: string[] } }>; + }; + + const issue = searchData.issues?.[0]; + if (!issue) { + // No issues in the project yet — labels will be created when first agent runs + return []; + } + + const existingLabels = issue.fields?.labels ?? []; + const newLabels = labelsToSeed.filter((l) => !existingLabels.includes(l)); + + if (newLabels.length === 0) { + // All labels already exist in the project + return labelsToSeed; + } + + // Add all CASCADE labels to the issue + const addResponse = await fetch(`${ctx.jiraBaseUrl}/rest/api/3/issue/${issue.key}`, { + method: 'PUT', + headers: { + Authorization: auth, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + fields: { + labels: [...existingLabels, ...newLabels], + }, + }), + }); + + if (!addResponse.ok) return []; + + // Immediately restore original labels + await fetch(`${ctx.jiraBaseUrl}/rest/api/3/issue/${issue.key}`, { + method: 'PUT', + headers: { + Authorization: auth, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + fields: { + labels: existingLabels, + }, + }), + }); + + return labelsToSeed; +} + +export class JiraWebhookAdapter implements WebhookPlatformAdapter { + readonly type = 'jira' as const; + + async list(ctx: ProjectContext): Promise { + return jiraListWebhooks(ctx); + } + + async create( + ctx: ProjectContext, + baseUrl: string, + ): Promise { + if (!ctx.jiraEmail || !ctx.jiraApiToken || !ctx.jiraBaseUrl) return undefined; + + const callbackUrl = `${baseUrl}/jira/webhook`; + const existing = await jiraListWebhooks(ctx); + const duplicate = existing.find( + (w) => w.url === callbackUrl || w.url === `${baseUrl}/webhook/jira`, + ); + + if (duplicate) { + return `Already exists: ${duplicate.id}`; + } + return jiraCreateWebhook(ctx, callbackUrl); + } + + async delete(ctx: ProjectContext, baseUrl: string): Promise { + if (!ctx.jiraEmail || !ctx.jiraApiToken) return []; + + const callbackUrl = `${baseUrl}/jira/webhook`; + const existing = await jiraListWebhooks(ctx); + const matching = existing.filter( + (w) => w.url === callbackUrl || w.url === `${baseUrl}/webhook/jira`, + ); + const deleted: number[] = []; + for (const w of matching) { + await jiraDeleteWebhook(ctx, w.id); + deleted.push(w.id); + } + return deleted; + } +} diff --git a/src/api/routers/webhooks/trello.ts b/src/api/routers/webhooks/trello.ts new file mode 100644 index 00000000..8ffac0ca --- /dev/null +++ b/src/api/routers/webhooks/trello.ts @@ -0,0 +1,94 @@ +import { TRPCError } from '@trpc/server'; +import type { ProjectContext, TrelloWebhook, WebhookPlatformAdapter } from './types.js'; + +async function trelloListWebhooks(ctx: ProjectContext): Promise { + if (!ctx.trelloApiKey || !ctx.trelloToken || !ctx.boardId) return []; + const response = await fetch( + `https://api.trello.com/1/tokens/${ctx.trelloToken}/webhooks?key=${ctx.trelloApiKey}`, + ); + if (!response.ok) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to list Trello webhooks: ${response.status}`, + }); + } + const webhooks = (await response.json()) as TrelloWebhook[]; + return webhooks.filter((w) => w.idModel === ctx.boardId); +} + +async function trelloCreateWebhook( + ctx: ProjectContext, + callbackURL: string, +): Promise { + const response = await fetch( + `https://api.trello.com/1/webhooks/?key=${ctx.trelloApiKey}&token=${ctx.trelloToken}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + callbackURL, + idModel: ctx.boardId, + description: `CASCADE webhook for project ${ctx.projectId}`, + }), + }, + ); + if (!response.ok) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to create Trello webhook: ${response.status}`, + }); + } + return (await response.json()) as TrelloWebhook; +} + +async function trelloDeleteWebhook(ctx: ProjectContext, webhookId: string): Promise { + const response = await fetch( + `https://api.trello.com/1/webhooks/${webhookId}?key=${ctx.trelloApiKey}&token=${ctx.trelloToken}`, + { method: 'DELETE' }, + ); + if (!response.ok) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to delete Trello webhook ${webhookId}: ${response.status}`, + }); + } +} + +export class TrelloWebhookAdapter implements WebhookPlatformAdapter { + readonly type = 'trello' as const; + + async list(ctx: ProjectContext): Promise { + return trelloListWebhooks(ctx); + } + + async create(ctx: ProjectContext, baseUrl: string): Promise { + if (!ctx.trelloApiKey || !ctx.trelloToken || !ctx.boardId) return undefined; + + const callbackUrl = `${baseUrl}/trello/webhook`; + const existing = await trelloListWebhooks(ctx); + const duplicate = existing.find( + (w) => w.callbackURL === callbackUrl || w.callbackURL === `${baseUrl}/webhook/trello`, + ); + + if (duplicate) { + return `Already exists: ${duplicate.id}`; + } + return trelloCreateWebhook(ctx, callbackUrl); + } + + async delete(ctx: ProjectContext, baseUrl: string): Promise { + if (!ctx.trelloApiKey || !ctx.trelloToken) return []; + + const callbackUrl = `${baseUrl}/trello/webhook`; + const existing = await trelloListWebhooks(ctx); + const matching = existing.filter( + (w) => w.callbackURL === callbackUrl || w.callbackURL === `${baseUrl}/webhook/trello`, + ); + const deleted: string[] = []; + for (const w of matching) { + await trelloDeleteWebhook(ctx, w.id); + deleted.push(w.id); + } + return deleted; + } +} diff --git a/src/api/routers/webhooks/types.ts b/src/api/routers/webhooks/types.ts new file mode 100644 index 00000000..43f0b139 --- /dev/null +++ b/src/api/routers/webhooks/types.ts @@ -0,0 +1,84 @@ +/** + * Webhook payload types — one per platform. + */ + +export interface TrelloWebhook { + id: string; + description: string; + idModel: string; + callbackURL: string; + active: boolean; +} + +export interface GitHubWebhook { + id: number; + name: string; + active: boolean; + events: string[]; + config: { url?: string; content_type?: string }; +} + +export interface JiraWebhookInfo { + id: number; + name: string; + url: string; + events: string[]; + enabled: boolean; +} + +/** + * Shared project context passed to all webhook platform adapters. + * Extracted from resolveProjectContext() in the webhooks router. + */ +export interface ProjectContext { + projectId: string; + orgId: string; + repo: string; + pmType: 'trello' | 'jira'; + boardId?: string; + jiraBaseUrl?: string; + jiraProjectKey?: string; + jiraLabels?: string[]; + trelloApiKey: string; + trelloToken: string; + githubToken: string; + jiraEmail?: string; + jiraApiToken?: string; +} + +/** + * Generic webhook info type — union of all platform webhook types. + */ +export type AnyWebhookInfo = TrelloWebhook | GitHubWebhook | JiraWebhookInfo; + +/** + * WebhookPlatformAdapter — per-platform pluggable behavior for the webhook + * management API (list, create, delete). + * + * Mirrors the `RouterPlatformAdapter` pattern from `src/router/platform-adapter.ts` + * but for the dashboard-side webhook management API. + */ +export interface WebhookPlatformAdapter { + /** Platform identifier used in results and logs. */ + readonly type: 'trello' | 'github' | 'jira'; + + /** + * List all webhooks for this platform that belong to the project. + * Returns an empty array when credentials are not configured. + */ + list(ctx: ProjectContext): Promise; + + /** + * Create a webhook for the given callback URL. + * Returns either the created webhook or a "Already exists: " string + * when a duplicate is detected. Returns undefined when credentials are + * not present or the platform is not applicable. + */ + create(ctx: ProjectContext, baseUrl: string): Promise; + + /** + * Delete all webhooks matching the given callback URL. + * Returns the list of IDs/numbers deleted. + */ + delete(ctx: ProjectContext, baseUrl: string): Promise>; +} diff --git a/tests/unit/api/routers/_shared/orgAccess.test.ts b/tests/unit/api/routers/_shared/orgAccess.test.ts new file mode 100644 index 00000000..625be403 --- /dev/null +++ b/tests/unit/api/routers/_shared/orgAccess.test.ts @@ -0,0 +1,103 @@ +import { TRPCError } from '@trpc/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// --- Mocks --- + +const mockGetRunById = vi.fn(); +vi.mock('../../../../../src/db/repositories/runsRepository.js', () => ({ + getRunById: (...args: unknown[]) => mockGetRunById(...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', () => ({ + projects: { id: 'id', orgId: 'org_id' }, +})); + +import { + verifyProjectOrgAccess, + verifyRunOrgAccess, +} from '../../../../../src/api/routers/_shared/orgAccess.js'; + +function setupDbChain(result: unknown[]) { + mockDbSelect.mockReturnValue({ from: mockDbFrom }); + mockDbFrom.mockReturnValue({ where: mockDbWhere }); + mockDbWhere.mockResolvedValue(result); +} + +describe('verifyProjectOrgAccess', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does not throw when project belongs to the org', async () => { + setupDbChain([{ orgId: 'org-1' }]); + await expect(verifyProjectOrgAccess('project-1', 'org-1')).resolves.toBeUndefined(); + }); + + it('throws NOT_FOUND when project does not exist', async () => { + setupDbChain([]); + await expect(verifyProjectOrgAccess('project-1', 'org-1')).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws NOT_FOUND when project belongs to a different org', async () => { + setupDbChain([{ orgId: 'other-org' }]); + await expect(verifyProjectOrgAccess('project-1', 'org-1')).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws a TRPCError (not a generic error)', async () => { + setupDbChain([]); + try { + await verifyProjectOrgAccess('project-1', 'org-1'); + expect.fail('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(TRPCError); + } + }); +}); + +describe('verifyRunOrgAccess', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws NOT_FOUND when run does not exist', async () => { + mockGetRunById.mockResolvedValue(null); + await expect(verifyRunOrgAccess('run-1', 'org-1')).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('does not perform org check when run has no projectId', async () => { + mockGetRunById.mockResolvedValue({ id: 'run-1', projectId: null }); + await expect(verifyRunOrgAccess('run-1', 'org-1')).resolves.toBeUndefined(); + expect(mockDbSelect).not.toHaveBeenCalled(); + }); + + it('verifies project org when run has a projectId', async () => { + mockGetRunById.mockResolvedValue({ id: 'run-1', projectId: 'project-1' }); + setupDbChain([{ orgId: 'org-1' }]); + await expect(verifyRunOrgAccess('run-1', 'org-1')).resolves.toBeUndefined(); + expect(mockDbSelect).toHaveBeenCalled(); + }); + + it('throws NOT_FOUND when run project belongs to different org', async () => { + mockGetRunById.mockResolvedValue({ id: 'run-1', projectId: 'project-1' }); + setupDbChain([{ orgId: 'other-org' }]); + await expect(verifyRunOrgAccess('run-1', 'org-1')).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); +}); diff --git a/tests/unit/api/routers/webhooks/adapters.test.ts b/tests/unit/api/routers/webhooks/adapters.test.ts new file mode 100644 index 00000000..37410ede --- /dev/null +++ b/tests/unit/api/routers/webhooks/adapters.test.ts @@ -0,0 +1,462 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ProjectContext } from '../../../../../src/api/routers/webhooks/types.js'; + +// --- Shared fetch mock --- +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +// --- Octokit mock --- +const mockListWebhooks = vi.fn(); +const mockCreateWebhook = vi.fn(); +const mockDeleteWebhook = vi.fn(); + +vi.mock('@octokit/rest', () => ({ + Octokit: vi.fn(() => ({ + repos: { + listWebhooks: mockListWebhooks, + createWebhook: mockCreateWebhook, + deleteWebhook: mockDeleteWebhook, + }, + })), +})); + +vi.mock('../../../../../src/utils/repo.js', () => ({ + parseRepoFullName: (fullName: string) => { + const slashIdx = fullName.indexOf('/'); + return { owner: fullName.slice(0, slashIdx), repo: fullName.slice(slashIdx + 1) }; + }, +})); + +import { GitHubWebhookAdapter } from '../../../../../src/api/routers/webhooks/github.js'; +import { + JiraWebhookAdapter, + jiraEnsureLabels, +} from '../../../../../src/api/routers/webhooks/jira.js'; +import { TrelloWebhookAdapter } from '../../../../../src/api/routers/webhooks/trello.js'; + +const trelloCtx: ProjectContext = { + projectId: 'proj-1', + orgId: 'org-1', + repo: 'owner/repo', + pmType: 'trello', + boardId: 'board-123', + trelloApiKey: 'key', + trelloToken: 'token', + githubToken: 'ghp_test', +}; + +const githubCtx: ProjectContext = { + projectId: 'proj-1', + orgId: 'org-1', + repo: 'owner/repo', + pmType: 'trello', + trelloApiKey: '', + trelloToken: '', + githubToken: 'ghp_test', +}; + +const jiraCtx: ProjectContext = { + projectId: 'proj-1', + orgId: 'org-1', + repo: 'owner/repo', + pmType: 'jira', + trelloApiKey: '', + trelloToken: '', + githubToken: '', + jiraBaseUrl: 'https://test.atlassian.net', + jiraEmail: 'bot@example.com', + jiraApiToken: 'jira-token', + jiraProjectKey: 'PROJ', + jiraLabels: ['cascade-processing', 'cascade-processed', 'cascade-error', 'cascade-ready'], +}; + +const BASE_URL = 'http://example.com'; + +// --------------------------------------------------------------------------- +// TrelloWebhookAdapter +// --------------------------------------------------------------------------- + +describe('TrelloWebhookAdapter', () => { + const adapter = new TrelloWebhookAdapter(); + + beforeEach(() => vi.clearAllMocks()); + + it('has type "trello"', () => { + expect(adapter.type).toBe('trello'); + }); + + describe('list', () => { + it('returns webhooks filtered by boardId', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve([ + { id: 'tw-1', idModel: 'board-123', callbackURL: 'http://x', active: true }, + { id: 'tw-2', idModel: 'other-board', callbackURL: 'http://y', active: true }, + ]), + }); + + const result = await adapter.list(trelloCtx); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('tw-1'); + }); + + it('returns empty array when credentials are missing', async () => { + const ctx = { ...trelloCtx, trelloApiKey: '', trelloToken: '' }; + const result = await adapter.list(ctx); + expect(result).toEqual([]); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('throws on HTTP error', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 401 }); + await expect(adapter.list(trelloCtx)).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + }); + }); + }); + + describe('create', () => { + it('creates a webhook when no duplicate exists', async () => { + // list returns empty, then create returns new webhook + mockFetch + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + id: 'tw-new', + callbackURL: `${BASE_URL}/trello/webhook`, + idModel: 'board-123', + active: true, + }), + }); + + const result = await adapter.create(trelloCtx, BASE_URL); + expect(result).toMatchObject({ id: 'tw-new' }); + }); + + it('returns "Already exists" when duplicate found', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve([ + { + id: 'tw-existing', + callbackURL: `${BASE_URL}/trello/webhook`, + idModel: 'board-123', + active: true, + }, + ]), + }); + + const result = await adapter.create(trelloCtx, BASE_URL); + expect(result).toBe('Already exists: tw-existing'); + }); + + it('returns undefined when credentials are missing', async () => { + const ctx = { ...trelloCtx, trelloApiKey: '' }; + const result = await adapter.create(ctx, BASE_URL); + expect(result).toBeUndefined(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('delete', () => { + it('deletes matching webhooks and returns their IDs', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve([ + { + id: 'tw-1', + callbackURL: `${BASE_URL}/trello/webhook`, + idModel: 'board-123', + active: true, + }, + ]), + }) + .mockResolvedValueOnce({ ok: true }); + + const result = await adapter.delete(trelloCtx, BASE_URL); + expect(result).toEqual(['tw-1']); + }); + + it('returns empty array when no matching webhooks', async () => { + mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve([]) }); + + const result = await adapter.delete(trelloCtx, BASE_URL); + expect(result).toEqual([]); + }); + }); +}); + +// --------------------------------------------------------------------------- +// GitHubWebhookAdapter +// --------------------------------------------------------------------------- + +describe('GitHubWebhookAdapter', () => { + const adapter = new GitHubWebhookAdapter(); + + beforeEach(() => vi.clearAllMocks()); + + it('has type "github"', () => { + expect(adapter.type).toBe('github'); + }); + + describe('list', () => { + it('returns github webhooks', async () => { + mockListWebhooks.mockResolvedValue({ + data: [{ id: 1, name: 'web', active: true, events: ['push'], config: { url: 'http://x' } }], + }); + + const result = await adapter.list(githubCtx); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(1); + }); + + it('returns empty array when no github token', async () => { + const ctx = { ...githubCtx, githubToken: '' }; + const result = await adapter.list(ctx); + expect(result).toEqual([]); + expect(mockListWebhooks).not.toHaveBeenCalled(); + }); + }); + + describe('create', () => { + it('creates webhook when no duplicate exists', async () => { + mockListWebhooks.mockResolvedValue({ data: [] }); + mockCreateWebhook.mockResolvedValue({ + data: { + id: 42, + config: { url: `${BASE_URL}/github/webhook` }, + events: ['pull_request'], + active: true, + }, + }); + + const result = await adapter.create(githubCtx, BASE_URL); + expect(result).toMatchObject({ id: 42 }); + }); + + it('returns "Already exists" when duplicate found', async () => { + mockListWebhooks.mockResolvedValue({ + data: [{ id: 99, config: { url: `${BASE_URL}/github/webhook` }, events: [], active: true }], + }); + + const result = await adapter.create(githubCtx, BASE_URL); + expect(result).toBe('Already exists: 99'); + }); + + it('returns undefined when no github token', async () => { + const ctx = { ...githubCtx, githubToken: '' }; + const result = await adapter.create(ctx, BASE_URL); + expect(result).toBeUndefined(); + }); + }); + + describe('delete', () => { + it('deletes matching webhooks and returns their IDs', async () => { + mockListWebhooks.mockResolvedValue({ + data: [{ id: 10, config: { url: `${BASE_URL}/github/webhook` }, events: [], active: true }], + }); + mockDeleteWebhook.mockResolvedValue({}); + + const result = await adapter.delete(githubCtx, BASE_URL); + expect(result).toEqual([10]); + }); + + it('returns empty array when no github token', async () => { + const ctx = { ...githubCtx, githubToken: '' }; + const result = await adapter.delete(ctx, BASE_URL); + expect(result).toEqual([]); + }); + }); +}); + +// --------------------------------------------------------------------------- +// JiraWebhookAdapter +// --------------------------------------------------------------------------- + +describe('JiraWebhookAdapter', () => { + const adapter = new JiraWebhookAdapter(); + + beforeEach(() => vi.clearAllMocks()); + + it('has type "jira"', () => { + expect(adapter.type).toBe('jira'); + }); + + describe('list', () => { + it('returns JIRA webhooks', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + values: [ + { id: 1, name: 'cascade-webhook', url: 'http://x', events: [], enabled: true }, + ], + }), + }); + + const result = await adapter.list(jiraCtx); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(1); + }); + + it('returns empty array when JIRA credentials missing', async () => { + const ctx = { ...jiraCtx, jiraEmail: undefined }; + const result = await adapter.list(ctx); + expect(result).toEqual([]); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('create', () => { + it('creates webhook when no duplicate exists', async () => { + mockFetch + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ values: [] }) }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + id: 100, + name: 'cascade-webhook', + url: `${BASE_URL}/jira/webhook`, + events: [], + enabled: true, + }), + }); + + const result = await adapter.create(jiraCtx, BASE_URL); + expect(result).toMatchObject({ id: 100 }); + }); + + it('returns "Already exists" when duplicate found', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + values: [ + { + id: 50, + name: 'cascade-webhook', + url: `${BASE_URL}/jira/webhook`, + events: [], + enabled: true, + }, + ], + }), + }); + + const result = await adapter.create(jiraCtx, BASE_URL); + expect(result).toBe('Already exists: 50'); + }); + + it('returns undefined when JIRA credentials missing', async () => { + const ctx = { ...jiraCtx, jiraEmail: undefined }; + const result = await adapter.create(ctx, BASE_URL); + expect(result).toBeUndefined(); + }); + }); + + describe('delete', () => { + it('deletes matching JIRA webhooks and returns their IDs', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + values: [ + { + id: 20, + name: 'w', + url: `${BASE_URL}/jira/webhook`, + events: [], + enabled: true, + }, + ], + }), + }) + .mockResolvedValueOnce({ ok: true }); + + const result = await adapter.delete(jiraCtx, BASE_URL); + expect(result).toEqual([20]); + }); + + it('returns empty array when JIRA credentials missing', async () => { + const ctx = { ...jiraCtx, jiraEmail: undefined }; + const result = await adapter.delete(ctx, BASE_URL); + expect(result).toEqual([]); + }); + }); +}); + +// --------------------------------------------------------------------------- +// jiraEnsureLabels +// --------------------------------------------------------------------------- + +describe('jiraEnsureLabels', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns empty array when credentials are missing', async () => { + const ctx = { ...jiraCtx, jiraEmail: undefined }; + const result = await jiraEnsureLabels(ctx); + expect(result).toEqual([]); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns empty array when no issues in project', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ issues: [] }), + }); + + const result = await jiraEnsureLabels(jiraCtx); + expect(result).toEqual([]); + }); + + it('seeds labels and returns them when new labels needed', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + issues: [{ key: 'PROJ-1', fields: { labels: ['existing'] } }], + }), + }) + .mockResolvedValueOnce({ ok: true }) // add labels + .mockResolvedValueOnce({ ok: true }); // restore labels + + const result = await jiraEnsureLabels(jiraCtx); + expect(result).toEqual(jiraCtx.jiraLabels); + }); + + it('returns labels without fetch when all labels already exist', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + issues: [ + { + key: 'PROJ-1', + fields: { + labels: [ + 'cascade-processing', + 'cascade-processed', + 'cascade-error', + 'cascade-ready', + ], + }, + }, + ], + }), + }); + + const result = await jiraEnsureLabels(jiraCtx); + expect(result).toEqual(jiraCtx.jiraLabels); + // Only the search call is made, no add/restore calls + expect(mockFetch).toHaveBeenCalledTimes(1); + }); +});