Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion src/api/routers/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
},
};
}),
Expand All @@ -85,6 +99,7 @@ export const webhooksRouter = router({
github?: GitHubWebhook | string;
jira?: JiraWebhookInfo | string;
sentry?: SentryWebhookInfo;
linear?: LinearWebhookInfo;
labelsEnsured?: string[];
} = {};

Expand Down Expand Up @@ -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;
}),

Expand Down
5 changes: 5 additions & 0 deletions src/api/routers/webhooks/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
};
}

Expand All @@ -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();

Expand All @@ -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;
}
8 changes: 8 additions & 0 deletions src/api/routers/webhooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -49,4 +55,6 @@ export interface ProjectContext {
webhookSecret?: string;
sentryConfigured?: boolean;
sentryWebhookSecretSet?: boolean;
linearApiKey?: string;
linearWebhookSecretSet?: boolean;
}
17 changes: 17 additions & 0 deletions src/cli/dashboard/webhooks/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
10 changes: 10 additions & 0 deletions src/cli/dashboard/webhooks/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
196 changes: 196 additions & 0 deletions tests/unit/api/routers/webhooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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<string, string> = {
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;
Expand Down Expand Up @@ -709,6 +738,7 @@ describe('webhooksRouter', () => {
trello: null,
github: null,
jira: null,
linear: null,
});
});

Expand Down Expand Up @@ -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();
});
});
});
Loading
Loading