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
15 changes: 15 additions & 0 deletions src/linear/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,21 @@ export const linearClient = {
return mapIssue(data.issue as RawIssue);
},

async getIssueProjectId(issueId: string): Promise<string | null> {
logger.debug('Fetching Linear issue project', { issueId });
const data = await linearGraphQL<{ issue: { project?: { id?: string } | null } | null }>(
`query GetIssueProject($id: String!) {
issue(id: $id) {
project {
id
}
}
}`,
{ id: issueId },
);
return data.issue?.project?.id ?? null;
},

async listIssues(filter?: {
teamId?: string;
projectId?: string;
Expand Down
109 changes: 89 additions & 20 deletions src/router/adapters/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* processRouterWebhook() function.
*/

import { withLinearCredentials } from '../../linear/client.js';
import { linearClient, withLinearCredentials } from '../../linear/client.js';
import type { LinearWebhookPayload } from '../../linear/types.js';
import type { TriggerRegistry } from '../../triggers/registry.js';
import type { TriggerContext, TriggerResult } from '../../types/index.js';
Expand All @@ -28,6 +28,12 @@ const PROCESSABLE_TYPES = ['Issue', 'Comment', 'IssueLabel'] as const;

type ProcessableType = (typeof PROCESSABLE_TYPES)[number];

function nestedId(value: unknown): string | undefined {
if (!value || typeof value !== 'object') return undefined;
const id = (value as Record<string, unknown>).id;
return typeof id === 'string' ? id : undefined;
}

// ============================================================================
// Extended parsed event for Linear
// ============================================================================
Expand All @@ -38,6 +44,16 @@ interface LinearParsedEvent extends ParsedWebhookEvent {
resourceType: string;
}

interface LinearProjectScopeInput {
project: RouterProjectConfig;
isCommentEvent: boolean;
workItemId: string | undefined;
data: Record<string, unknown>;
issue: Record<string, unknown> | undefined;
eventType: string;
teamId: string;
}

// ============================================================================
// Adapter
// ============================================================================
Expand All @@ -58,9 +74,12 @@ export class LinearRouterAdapter implements RouterPlatformAdapter {
return null;
}

// Extract teamId from payload data for project lookup
// Extract teamId from payload data for project lookup. Linear Comment and
// IssueLabel webhooks can nest issue context under data.issue instead of
// repeating teamId at data.teamId.
const data = p.data as Record<string, unknown>;
const teamId = data.teamId as string | undefined;
const issue = data.issue as Record<string, unknown> | undefined;
const teamId = (data.teamId as string | undefined) ?? (issue?.teamId as string | undefined);

if (!teamId) {
logger.debug('LinearRouterAdapter: no teamId in payload data, skipping');
Expand All @@ -84,23 +103,17 @@ export class LinearRouterAdapter implements RouterPlatformAdapter {
// to a specific Linear Project, drop webhook events whose issue is not in
// that project. Linear cannot scope webhooks to a project, so the filter
// runs here, after team-match.
const configuredProjectId = project.linear?.projectId;
if (configuredProjectId) {
const issueProjectId = isCommentEvent
? ((data.issue as Record<string, unknown> | undefined)?.projectId as string | undefined)
: (data.projectId as string | undefined);
if (issueProjectId !== configuredProjectId) {
logger.info('LinearRouterAdapter: dropping event outside project scope', {
reason: issueProjectId ? 'project scope mismatch' : 'issue has no project',
configuredProjectId,
issueProjectId,
issueId: workItemId,
teamId,
projectId: project.id,
eventType,
});
return null;
}
const matchesProjectScope = await this.matchesConfiguredProjectScope({
project,
isCommentEvent,
workItemId,
data,
issue,
eventType,
teamId,
});
if (!matchesProjectScope) {
return null;
}

return {
Expand All @@ -114,6 +127,62 @@ export class LinearRouterAdapter implements RouterPlatformAdapter {
};
}

private async matchesConfiguredProjectScope(input: LinearProjectScopeInput): Promise<boolean> {
const configuredProjectId = input.project.linear?.projectId;
if (!configuredProjectId) return true;

const payloadProjectId = input.isCommentEvent
? ((input.issue?.projectId as string | undefined) ?? nestedId(input.issue?.project))
: ((input.data.projectId as string | undefined) ?? nestedId(input.data.project));
const issueProjectId =
payloadProjectId ??
(input.isCommentEvent && input.workItemId
? await this.fetchIssueProjectId(input.project.id, input.workItemId)
: undefined);

if (issueProjectId === configuredProjectId) return true;

logger.info('LinearRouterAdapter: dropping event outside project scope', {
reason: issueProjectId ? 'project scope mismatch' : 'issue has no project',
configuredProjectId,
issueProjectId,
issueId: input.workItemId,
teamId: input.teamId,
projectId: input.project.id,
eventType: input.eventType,
});
return false;
}

private async fetchIssueProjectId(
projectId: string,
issueId: string,
): Promise<string | undefined> {
const linearCreds = await resolveLinearCredentials(projectId);
if (!linearCreds) {
logger.warn('LinearRouterAdapter: missing Linear credentials, cannot fetch issue project', {
projectId,
issueId,
});
return undefined;
}

try {
return (
(await withLinearCredentials({ apiKey: linearCreds.apiKey }, () =>
linearClient.getIssueProjectId(issueId),
)) ?? undefined
);
} catch (err) {
logger.warn('LinearRouterAdapter: failed to fetch issue project', {
error: String(err),
projectId,
issueId,
});
return undefined;
}
}

isProcessableEvent(event: ParsedWebhookEvent): boolean {
// All parsed events are processable (we filter in parseWebhook)
return PROCESSABLE_TYPES.some((t) => event.eventType.endsWith(`/${t}`));
Expand Down
54 changes: 47 additions & 7 deletions src/router/bot-identity-resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,31 +80,71 @@ export function _resetTrelloBotCache(): void {
// Linear bot identity
// ---------------------------------------------------------------------------

export interface LinearBotIdentity {
id: string;
name: string;
email: string;
displayName: string;
}

const linearBotIdentityDetailsCache = new BotIdentityCache<LinearBotIdentity>('user');
const linearBotIdentityCache = new BotIdentityCache<string>('userId');

/**
* Resolve the Linear user ID for the bot credentials linked to a project.
* Uses the `viewer` query to fetch the authenticated user's ID.
* Resolve the Linear user identity for the bot credentials linked to a project.
* Uses the `viewer` query to fetch the authenticated user.
* Cached per-project with 60s TTL. Returns null on any failure.
*/
export async function resolveLinearBotUserId(projectId: string): Promise<string | null> {
return linearBotIdentityCache.resolve(projectId, async () => {
export async function resolveLinearBotIdentity(
projectId: string,
): Promise<LinearBotIdentity | null> {
return linearBotIdentityDetailsCache.resolve(projectId, async () => {
const creds = await resolveLinearCredentials(projectId);
if (!creds) return null;

const response = await fetch('https://api.linear.app/graphql', {
method: 'POST',
headers: linearAuthHeader(creds.apiKey),
body: JSON.stringify({ query: '{ viewer { id } }' }),
body: JSON.stringify({
query: '{ viewer { id name email displayName } }',
}),
});
if (!response.ok) return null;

const data = (await response.json()) as { data?: { viewer?: { id?: string } } };
return data.data?.viewer?.id ?? null;
const data = (await response.json()) as {
data?: {
viewer?: {
id?: string;
name?: string;
email?: string;
displayName?: string;
};
};
};
const viewer = data.data?.viewer;
if (!viewer?.id) return null;
return {
id: viewer.id,
name: viewer.name ?? '',
email: viewer.email ?? '',
displayName: viewer.displayName ?? viewer.name ?? '',
};
});
}

/**
* Resolve the Linear user ID for the bot credentials linked to a project.
* Cached per-project with 60s TTL. Returns null on any failure.
*/
export async function resolveLinearBotUserId(projectId: string): Promise<string | null> {
return linearBotIdentityCache.resolve(projectId, async () => {
const identity = await resolveLinearBotIdentity(projectId);
return identity?.id ?? null;
});
}

/** @internal Visible for testing only */
export function _resetLinearBotCache(): void {
linearBotIdentityDetailsCache._reset();
linearBotIdentityCache._reset();
}
39 changes: 31 additions & 8 deletions src/triggers/linear/comment-mention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* data.issue.identifier: the issue identifier (e.g. TEAM-123)
*/

import { resolveLinearBotUserId } from '../../router/bot-identity-resolvers.js';
import { resolveLinearBotIdentity } from '../../router/bot-identity-resolvers.js';
import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js';
import { logger } from '../../utils/logging.js';
import { checkTriggerEnabled } from '../shared/trigger-check.js';
Expand All @@ -21,10 +21,32 @@ import type { LinearWebhookCommentTriggerData, LinearWebhookTriggerPayload } fro
/**
* Check if a Linear comment body contains an @mention for the given user ID.
* Linear uses @[Display Name](userId) markdown mention syntax, where userId is
* a UUID. Checking for userId as a substring is sufficient and safe in practice.
* a UUID. Some Linear webhook payloads normalize mentions to plain @handles, so
* also compare against stable aliases derived from the authenticated bot user.
*/
function hasMention(body: string, userId: string): boolean {
return body.includes(userId);
function hasMention(
body: string,
identity: { id: string; name: string; displayName: string; email: string },
): boolean {
if (body.includes(identity.id)) return true;

const mentionedHandles = new Set(
Array.from(body.matchAll(/@([A-Za-z0-9._-]+)/g), (match) => match[1]?.toLowerCase()).filter(
Boolean,
),
);
if (mentionedHandles.size === 0) return false;

const aliases = new Set(
[identity.name, identity.displayName, identity.email.split('@')[0]]
.map((value) => value.trim().toLowerCase())
.filter(Boolean),
);

for (const alias of aliases) {
if (mentionedHandles.has(alias)) return true;
}
return false;
}

export class LinearCommentMentionTrigger implements TriggerHandler {
Expand Down Expand Up @@ -75,10 +97,11 @@ export class LinearCommentMentionTrigger implements TriggerHandler {
return null;
}

// Resolve the bot's Linear user ID via the shared cached resolver
const botUserId = await resolveLinearBotUserId(ctx.project.id);
// Resolve the bot's Linear identity via the shared cached resolver
const botIdentity = await resolveLinearBotIdentity(ctx.project.id);
const botUserId = botIdentity?.id;

if (!botUserId) {
if (!botIdentity) {
logger.warn('Linear comment trigger: could not resolve bot user ID, skipping', {
projectId: ctx.project.id,
});
Expand All @@ -97,7 +120,7 @@ export class LinearCommentMentionTrigger implements TriggerHandler {
}

// Check for bot @mention in comment body
const mentionFound = hasMention(commentBody, botUserId);
const mentionFound = hasMention(commentBody, botIdentity);
if (!mentionFound) {
logger.info('Linear comment trigger: no @mention of bot found in comment body', {
issueIdentifier,
Expand Down
31 changes: 31 additions & 0 deletions tests/unit/linear/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,37 @@ describe('linearClient.createIssue — projectId passthrough', () => {
});
});

describe('linearClient.getIssueProjectId', () => {
const originalFetch = globalThis.fetch;

afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});

it('returns issue project id', async () => {
const { calls } = stubFetch({ issue: { project: { id: 'P1' } } });

const projectId = await withLinearCredentials({ apiKey: 'k' }, () =>
linearClient.getIssueProjectId('issue-1'),
);

expect(projectId).toBe('P1');
expect(calls[0].body.query).toContain('query GetIssueProject');
expect(calls[0].body.variables).toEqual({ id: 'issue-1' });
});

it('returns null when issue has no project', async () => {
stubFetch({ issue: { project: null } });

const projectId = await withLinearCredentials({ apiKey: 'k' }, () =>
linearClient.getIssueProjectId('issue-1'),
);

expect(projectId).toBeNull();
});
});

describe('linearClient.getTeamProjects', () => {
const originalFetch = globalThis.fetch;

Expand Down
Loading