From d192db406ad6226c5e8497054499431e9fb3f221 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Tue, 14 Apr 2026 22:28:07 +0000 Subject: [PATCH] feat(webhooks): add Linear webhook info to webhooks dashboard and CLI tool --- src/api/routers/webhooks.ts | 26 +++- src/api/routers/webhooks/context.ts | 5 + src/api/routers/webhooks/types.ts | 8 + src/cli/dashboard/webhooks/create.ts | 17 ++ src/cli/dashboard/webhooks/list.ts | 10 ++ tests/unit/api/routers/webhooks.test.ts | 196 ++++++++++++++++++++++++ tools/setup-webhooks.ts | 59 ++++++- 7 files changed, 315 insertions(+), 6 deletions(-) diff --git a/src/api/routers/webhooks.ts b/src/api/routers/webhooks.ts index 1aa412c4..4c4096ae 100644 --- a/src/api/routers/webhooks.ts +++ b/src/api/routers/webhooks.ts @@ -16,11 +16,12 @@ import { trelloCreateWebhook, trelloDeleteWebhook, trelloListWebhooks } from './ import type { GitHubWebhook, JiraWebhookInfo, + LinearWebhookInfo, SentryWebhookInfo, TrelloWebhook, } from './webhooks/types.js'; -export type { GitHubWebhook, JiraWebhookInfo, SentryWebhookInfo, TrelloWebhook }; +export type { GitHubWebhook, JiraWebhookInfo, LinearWebhookInfo, SentryWebhookInfo, TrelloWebhook }; export const webhooksRouter = router({ list: adminProcedure @@ -52,15 +53,28 @@ export const webhooksRouter = router({ }; } + // Linear — informational only (webhooks must be configured in Linear team settings) + let linear: LinearWebhookInfo | null = null; + if (input.callbackBaseUrl && pctx.pmType === 'linear' && pctx.linearApiKey) { + const baseUrl = input.callbackBaseUrl.replace(/\/$/, ''); + linear = { + url: `${baseUrl}/linear/webhook`, + webhookSecretSet: pctx.linearWebhookSecretSet ?? false, + note: 'Configure this URL in your Linear team settings under API > Webhooks.', + }; + } + return { trello: trelloResult.status === 'fulfilled' ? trelloResult.value : [], github: githubResult.status === 'fulfilled' ? githubResult.value : [], jira: jiraResult.status === 'fulfilled' ? jiraResult.value : [], sentry, + linear, errors: { trello: trelloResult.status === 'rejected' ? String(trelloResult.reason) : null, github: githubResult.status === 'rejected' ? String(githubResult.reason) : null, jira: jiraResult.status === 'rejected' ? String(jiraResult.reason) : null, + linear: null, }, }; }), @@ -85,6 +99,7 @@ export const webhooksRouter = router({ github?: GitHubWebhook | string; jira?: JiraWebhookInfo | string; sentry?: SentryWebhookInfo; + linear?: LinearWebhookInfo; labelsEnsured?: string[]; } = {}; @@ -158,6 +173,15 @@ export const webhooksRouter = router({ }; } + // Linear — display-only (cannot create programmatically) + if (pctx.pmType === 'linear' && pctx.linearApiKey) { + results.linear = { + url: `${baseUrl}/linear/webhook`, + webhookSecretSet: pctx.linearWebhookSecretSet ?? false, + note: 'Configure this URL manually in your Linear team settings under API > Webhooks.', + }; + } + return results; }), diff --git a/src/api/routers/webhooks/context.ts b/src/api/routers/webhooks/context.ts index 0d3a8636..b4acc193 100644 --- a/src/api/routers/webhooks/context.ts +++ b/src/api/routers/webhooks/context.ts @@ -7,6 +7,7 @@ import { getJiraConfig, getTrelloConfig } from '../../../pm/config.js'; import { verifyProjectOrgAccess } from '../_shared/projectAccess.js'; import type { ProjectContext } from './types.js'; +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: multi-provider credential resolution export async function resolveProjectContext( projectId: string, userOrgId: string, @@ -55,6 +56,8 @@ export async function resolveProjectContext( webhookSecret: creds.GITHUB_WEBHOOK_SECRET ?? undefined, sentryConfigured, sentryWebhookSecretSet: !!creds.SENTRY_WEBHOOK_SECRET, + linearApiKey: creds.LINEAR_API_KEY ?? undefined, + linearWebhookSecretSet: !!creds.LINEAR_WEBHOOK_SECRET, }; } @@ -65,6 +68,7 @@ export const oneTimeTokensSchema = z trelloToken: z.string().optional(), jiraEmail: z.string().optional(), jiraApiToken: z.string().optional(), + linearApiKey: z.string().optional(), }) .optional(); @@ -77,4 +81,5 @@ export function applyOneTimeTokens(pctx: ProjectContext, tokens: OneTimeTokens): if (tokens.trelloToken) pctx.trelloToken = tokens.trelloToken; if (tokens.jiraEmail) pctx.jiraEmail = tokens.jiraEmail; if (tokens.jiraApiToken) pctx.jiraApiToken = tokens.jiraApiToken; + if (tokens.linearApiKey) pctx.linearApiKey = tokens.linearApiKey; } diff --git a/src/api/routers/webhooks/types.ts b/src/api/routers/webhooks/types.ts index b97f83cd..b3da303a 100644 --- a/src/api/routers/webhooks/types.ts +++ b/src/api/routers/webhooks/types.ts @@ -32,6 +32,12 @@ export interface SentryWebhookInfo { note: string; } +export interface LinearWebhookInfo { + url: string; + webhookSecretSet: boolean; + note: string; +} + export interface ProjectContext { projectId: string; orgId: string; @@ -49,4 +55,6 @@ export interface ProjectContext { webhookSecret?: string; sentryConfigured?: boolean; sentryWebhookSecretSet?: boolean; + linearApiKey?: string; + linearWebhookSecretSet?: boolean; } diff --git a/src/cli/dashboard/webhooks/create.ts b/src/cli/dashboard/webhooks/create.ts index fca76db1..e990aa3b 100644 --- a/src/cli/dashboard/webhooks/create.ts +++ b/src/cli/dashboard/webhooks/create.ts @@ -93,6 +93,23 @@ export default class WebhooksCreate extends DashboardCommand { this.log(' 5. Copy the Client Secret and save it as SENTRY_WEBHOOK_SECRET credential'); } } + + if (result.linear) { + this.log(''); + this.log('Linear (manual setup required):'); + this.log(` Webhook URL: ${result.linear.url}`); + this.log(` Webhook secret: ${result.linear.webhookSecretSet ? 'configured' : 'not set'}`); + this.log(' Steps:'); + this.log(' 1. Go to Linear > Settings > API > Webhooks'); + this.log(' 2. Click "New webhook"'); + this.log(' 3. Set the URL to the Webhook URL above'); + this.log(' 4. Select the desired event types (e.g. Issues, Comments)'); + if (!result.linear.webhookSecretSet) { + this.log( + ' 5. Copy the signing secret and save it as LINEAR_WEBHOOK_SECRET credential', + ); + } + } } catch (err) { this.handleError(err); } diff --git a/src/cli/dashboard/webhooks/list.ts b/src/cli/dashboard/webhooks/list.ts index 495f90cc..683c846e 100644 --- a/src/cli/dashboard/webhooks/list.ts +++ b/src/cli/dashboard/webhooks/list.ts @@ -92,6 +92,16 @@ export default class WebhooksList extends DashboardCommand { } else { this.log(' (not configured)'); } + + this.log(''); + this.log('Linear webhook:'); + if (result.linear) { + this.log(` URL: ${result.linear.url}`); + this.log(` Webhook secret: ${result.linear.webhookSecretSet ? 'configured' : 'not set'}`); + this.log(` ${result.linear.note}`); + } else { + this.log(' (not configured)'); + } } catch (err) { this.handleError(err); } diff --git a/tests/unit/api/routers/webhooks.test.ts b/tests/unit/api/routers/webhooks.test.ts index 18b27735..c377d15d 100644 --- a/tests/unit/api/routers/webhooks.test.ts +++ b/tests/unit/api/routers/webhooks.test.ts @@ -102,6 +102,17 @@ const mockJiraProject = { }, }; +const mockLinearProject = { + id: 'linear-project', + orgId: 'org-1', + repo: 'owner/linear-repo', + pm: { type: 'linear' }, + linear: { + teamId: 'TEAM-123', + statuses: { todo: 'Todo', inProgress: 'In Progress' }, + }, +}; + function setupJiraProjectContext() { mockDbSelect.mockReturnValue({ from: mockDbFrom }); mockDbFrom.mockReturnValue({ where: mockDbWhere }); @@ -115,6 +126,24 @@ function setupJiraProjectContext() { }); } +function setupLinearProjectContext(opts?: { noLinearApiKey?: boolean; webhookSecret?: boolean }) { + mockDbSelect.mockReturnValue({ from: mockDbFrom }); + mockDbFrom.mockReturnValue({ where: mockDbWhere }); + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockFindProjectByIdFromDb.mockResolvedValue(mockLinearProject); + mockGetIntegrationByProjectAndCategory.mockResolvedValue(null); + const creds: Record = { + GITHUB_TOKEN_IMPLEMENTER: 'ghp_test123', + }; + if (!opts?.noLinearApiKey) { + creds.LINEAR_API_KEY = 'lin_api_test123'; + } + if (opts?.webhookSecret) { + creds.LINEAR_WEBHOOK_SECRET = 'linear-secret-abc'; + } + mockGetAllProjectCredentials.mockResolvedValue(creds); +} + function setupProjectContext(opts?: { noTrello?: boolean; noGithub?: boolean; @@ -709,6 +738,7 @@ describe('webhooksRouter', () => { trello: null, github: null, jira: null, + linear: null, }); }); @@ -914,5 +944,171 @@ describe('webhooksRouter', () => { expect(result.errors.github).toBeNull(); expect(result.errors.jira).toBeNull(); }); + + it('list uses linearApiKey oneTimeToken to show Linear webhook info', async () => { + setupLinearProjectContext({ noLinearApiKey: true }); + + mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve([]) }); + mockListWebhooks.mockResolvedValue({ data: [] }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.list({ + projectId: 'linear-project', + callbackBaseUrl: 'https://cascade.example.com', + oneTimeTokens: { linearApiKey: 'lin_api_onetime' }, + }); + + expect(result.linear).not.toBeNull(); + expect(result.linear?.url).toBe('https://cascade.example.com/linear/webhook'); + }); + }); + + describe('Linear webhook info', () => { + it('list returns linear webhook info when project uses Linear PM and has linearApiKey', async () => { + setupLinearProjectContext(); + + mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve([]) }); + mockListWebhooks.mockResolvedValue({ data: [] }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.list({ + projectId: 'linear-project', + callbackBaseUrl: 'https://cascade.example.com', + }); + + expect(result.linear).not.toBeNull(); + expect(result.linear?.url).toBe('https://cascade.example.com/linear/webhook'); + expect(result.linear?.webhookSecretSet).toBe(false); + expect(result.linear?.note).toContain('Linear'); + }); + + it('list returns linear webhook info with webhookSecretSet true when LINEAR_WEBHOOK_SECRET is set', async () => { + setupLinearProjectContext({ webhookSecret: true }); + + mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve([]) }); + mockListWebhooks.mockResolvedValue({ data: [] }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.list({ + projectId: 'linear-project', + callbackBaseUrl: 'https://cascade.example.com', + }); + + expect(result.linear?.webhookSecretSet).toBe(true); + }); + + it('list returns null linear when project uses Linear PM but no linearApiKey', async () => { + setupLinearProjectContext({ noLinearApiKey: true }); + + mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve([]) }); + mockListWebhooks.mockResolvedValue({ data: [] }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.list({ + projectId: 'linear-project', + callbackBaseUrl: 'https://cascade.example.com', + }); + + expect(result.linear).toBeNull(); + }); + + it('list returns null linear when no callbackBaseUrl is provided', async () => { + setupLinearProjectContext(); + + mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve([]) }); + mockListWebhooks.mockResolvedValue({ data: [] }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.list({ + projectId: 'linear-project', + }); + + expect(result.linear).toBeNull(); + }); + + it('list errors object includes linear: null', async () => { + setupLinearProjectContext(); + + mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve([]) }); + mockListWebhooks.mockResolvedValue({ data: [] }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.list({ + projectId: 'linear-project', + callbackBaseUrl: 'https://cascade.example.com', + }); + + expect(result.errors.linear).toBeNull(); + }); + + it('create returns linear webhook info for Linear PM projects', async () => { + setupLinearProjectContext(); + + mockListWebhooks.mockResolvedValue({ data: [] }); + mockCreateWebhook.mockResolvedValue({ + data: { + id: 1, + config: { url: 'http://example.com/github/webhook' }, + events: [], + active: true, + }, + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.create({ + projectId: 'linear-project', + callbackBaseUrl: 'https://cascade.example.com', + }); + + expect(result.linear).not.toBeUndefined(); + expect(result.linear?.url).toBe('https://cascade.example.com/linear/webhook'); + expect(result.linear?.webhookSecretSet).toBe(false); + expect(result.linear?.note).toContain('Linear'); + }); + + it('create returns linear webhook info with webhookSecretSet true when LINEAR_WEBHOOK_SECRET is set', async () => { + setupLinearProjectContext({ webhookSecret: true }); + + mockListWebhooks.mockResolvedValue({ data: [] }); + mockCreateWebhook.mockResolvedValue({ + data: { + id: 1, + config: { url: 'http://example.com/github/webhook' }, + events: [], + active: true, + }, + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.create({ + projectId: 'linear-project', + callbackBaseUrl: 'https://cascade.example.com', + }); + + expect(result.linear?.webhookSecretSet).toBe(true); + }); + + it('create does not return linear info for non-Linear PM projects', async () => { + setupProjectContext(); + + mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve([]) }); + mockListWebhooks.mockResolvedValue({ data: [] }); + mockCreateWebhook.mockResolvedValue({ + data: { + id: 1, + config: { url: 'http://example.com/github/webhook' }, + events: [], + active: true, + }, + }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.create({ + projectId: 'my-project', + callbackBaseUrl: 'https://cascade.example.com', + }); + + expect(result.linear).toBeUndefined(); + }); }); }); diff --git a/tools/setup-webhooks.ts b/tools/setup-webhooks.ts index 59c676ae..bcca8fcc 100644 --- a/tools/setup-webhooks.ts +++ b/tools/setup-webhooks.ts @@ -53,11 +53,14 @@ interface ProjectContext { projectId: string; orgId: string; repo: string | null; + pmType: string; boardId: string; trelloApiKey: string; trelloToken: string; githubToken: string; webhookSecret?: string; + linearApiKey?: string; + linearWebhookSecretSet: boolean; } async function resolveProjectContext(projectId: string): Promise { @@ -73,8 +76,9 @@ async function resolveProjectContext(projectId: string): Promise const trelloApiKey = credMap.TRELLO_API_KEY; const trelloToken = credMap.TRELLO_TOKEN; const githubToken = credMap.GITHUB_TOKEN_IMPLEMENTER ?? credMap.GITHUB_TOKEN; + const pmType = project.pm?.type ?? 'trello'; - if (!trelloApiKey || !trelloToken) { + if (pmType === 'trello' && (!trelloApiKey || !trelloToken)) { console.warn( 'Warning: TRELLO_API_KEY or TRELLO_TOKEN not found — Trello operations will be skipped', ); @@ -87,11 +91,14 @@ async function resolveProjectContext(projectId: string): Promise projectId, orgId: project.orgId, repo: project.repo ?? null, - boardId: project.trello.boardId, + pmType, + boardId: project.trello?.boardId ?? '', trelloApiKey: trelloApiKey ?? '', trelloToken: trelloToken ?? '', githubToken: githubToken ?? '', webhookSecret: credMap.GITHUB_WEBHOOK_SECRET ?? undefined, + linearApiKey: credMap.LINEAR_API_KEY ?? undefined, + linearWebhookSecretSet: !!credMap.LINEAR_WEBHOOK_SECRET, }; } @@ -240,6 +247,7 @@ function printGitHubWebhooks(webhooks: GitHubWebhook[]): void { // --- Command handlers --- +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: multi-provider webhook listing async function handleList(args: string[]): Promise { const projectId = args[1]; if (!projectId) { @@ -254,10 +262,13 @@ async function handleList(args: string[]): Promise { console.log(`Project: ${ctx.projectId} (org: ${ctx.orgId})`); console.log(`Repo: ${ctx.repo ?? '(none - email-only project)'}`); - console.log(`Trello board: ${ctx.boardId}`); + console.log(`PM type: ${ctx.pmType}`); + if (ctx.pmType === 'trello') { + console.log(`Trello board: ${ctx.boardId}`); + } console.log(''); - if (!githubOnly && ctx.trelloApiKey && ctx.trelloToken) { + if (!githubOnly && ctx.trelloApiKey && ctx.trelloToken && ctx.pmType === 'trello') { printTrelloWebhooks(await trelloListWebhooks(ctx)); } @@ -267,6 +278,28 @@ async function handleList(args: string[]): Promise { console.log('GitHub webhooks: (skipped - no repo configured)'); console.log(''); } + + // Linear — informational only (webhooks must be configured in Linear team settings) + if (ctx.pmType === 'linear') { + console.log('Linear webhook:'); + if (ctx.linearApiKey) { + const callbackBaseUrl = process.env.WEBHOOK_CALLBACK_BASE_URL; + if (callbackBaseUrl) { + const baseUrl = callbackBaseUrl.replace(/\/$/, ''); + console.log(` Webhook URL: ${baseUrl}/linear/webhook`); + console.log(` Webhook secret: ${ctx.linearWebhookSecretSet ? 'configured' : 'not set'}`); + } else { + console.log( + ' Webhook URL: /linear/webhook (set WEBHOOK_CALLBACK_BASE_URL to see full URL)', + ); + console.log(` Webhook secret: ${ctx.linearWebhookSecretSet ? 'configured' : 'not set'}`); + } + console.log(' Note: Configure this URL in your Linear team settings under API > Webhooks.'); + } else { + console.log(' (LINEAR_API_KEY not configured)'); + } + console.log(''); + } } async function createTrelloWebhookIfNeeded( @@ -317,7 +350,7 @@ async function handleCreate(args: string[]): Promise { const baseUrl = callbackBaseUrl.replace(/\/$/, ''); // Trello webhook - if (!githubOnly && ctx.trelloApiKey && ctx.trelloToken) { + if (!githubOnly && ctx.trelloApiKey && ctx.trelloToken && ctx.pmType === 'trello') { await createTrelloWebhookIfNeeded(ctx, `${baseUrl}/webhook/trello`); } @@ -327,6 +360,22 @@ async function handleCreate(args: string[]): Promise { } else if (!trelloOnly && !ctx.repo) { console.log('Skipping GitHub webhook: no repo configured for this project'); } + + // Linear — display-only (cannot create programmatically) + if (ctx.pmType === 'linear' && ctx.linearApiKey) { + console.log(''); + console.log('Linear (manual setup required):'); + console.log(` Webhook URL: ${baseUrl}/linear/webhook`); + console.log(` Webhook secret: ${ctx.linearWebhookSecretSet ? 'configured' : 'not set'}`); + console.log(' Steps:'); + console.log(' 1. Go to Linear > Settings > API > Webhooks'); + console.log(' 2. Click "New webhook"'); + console.log(' 3. Set the URL to the Webhook URL above'); + console.log(' 4. Select the desired event types (e.g. Issues, Comments)'); + if (!ctx.linearWebhookSecretSet) { + console.log(' 5. Copy the signing secret and save it as LINEAR_WEBHOOK_SECRET credential'); + } + } } async function deleteTrelloWebhooksForUrl(ctx: ProjectContext, callbackUrl: string): Promise {