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: 26 additions & 0 deletions src/router/ackMessageGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,32 @@ export function extractJiraContext(payload: unknown): string {
return truncate(parts.join('\n'));
}

/**
* Extract context from a Linear webhook payload.
* Pulls issue title and optional comment body.
*/
export function extractLinearContext(payload: unknown): string {
if (!payload || typeof payload !== 'object') return '';

const p = payload as Record<string, unknown>;
const parts: string[] = [];

const data = p.data as Record<string, unknown> | undefined;
if (!data) return '';

// Issue title (present for Issue and Comment events)
if (data.title) {
parts.push(`Issue: ${data.title as string}`);
}

// Comment body (present for Comment events)
if (data.body) {
parts.push(`Comment: ${data.body as string}`);
}

return truncate(parts.join('\n'));
}

// ---------------------------------------------------------------------------
// Core generator
// ---------------------------------------------------------------------------
Expand Down
26 changes: 26 additions & 0 deletions src/router/acknowledgments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import {
GitHubPlatformClient,
JiraPlatformClient,
LinearPlatformClient,
TrelloPlatformClient,
} from './platformClients/index.js';

Expand Down Expand Up @@ -90,15 +91,40 @@ export async function deleteJiraAck(
await client.deleteComment(issueKey, commentId);
}

// ---------------------------------------------------------------------------
// Linear — delegates to LinearPlatformClient
// ---------------------------------------------------------------------------

export async function postLinearAck(
projectId: string,
issueId: string,
message: string,
): Promise<string | null> {
const client = new LinearPlatformClient(projectId);
const result = await client.postComment(issueId, message);
return typeof result === 'string' ? result : null;
}

export async function deleteLinearAck(
projectId: string,
issueId: string,
commentId: string,
): Promise<void> {
const client = new LinearPlatformClient(projectId);
await client.deleteComment(issueId, commentId);
}

// ---------------------------------------------------------------------------
// Bot identity resolution — re-exported from bot-identity-resolvers.ts
// for backward compatibility with pm/ integrations and router/trello.ts.
// ---------------------------------------------------------------------------

export {
_resetJiraBotCache,
_resetLinearBotCache,
_resetTrelloBotCache,
resolveJiraBotAccountId,
resolveLinearBotUserId,
resolveTrelloBotMemberId,
} from './bot-identity-resolvers.js';

Expand Down
214 changes: 214 additions & 0 deletions src/router/adapters/linear.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
/**
* LinearRouterAdapter — platform-specific logic for the router-side
* Linear webhook processing pipeline.
*
* Follows the same pattern as JiraRouterAdapter and SentryRouterAdapter,
* implementing RouterPlatformAdapter so it can be driven by the generic
* processRouterWebhook() function.
*/

import { 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';
import { logger } from '../../utils/logging.js';
import { buildWorkItemRunsLink, getDashboardUrl } from '../../utils/runLink.js';
import { extractLinearContext, generateAckMessage } from '../ackMessageGenerator.js';
import { postLinearAck, resolveLinearBotUserId } from '../acknowledgments.js';
import { loadProjectConfig, type RouterProjectConfig } from '../config.js';
import type { AckResult, ParsedWebhookEvent, RouterPlatformAdapter } from '../platform-adapter.js';
import { resolveLinearCredentials } from '../platformClients/index.js';
import type { CascadeJob, LinearJob } from '../queue.js';

// ============================================================================
// Processable event combinations (action/type)
// ============================================================================

const PROCESSABLE_TYPES = ['Issue', 'Comment', 'IssueLabel'] as const;

type ProcessableType = (typeof PROCESSABLE_TYPES)[number];

// ============================================================================
// Extended parsed event for Linear
// ============================================================================

interface LinearParsedEvent extends ParsedWebhookEvent {
projectId: string;
action: string;
resourceType: string;
}

// ============================================================================
// Adapter
// ============================================================================

export class LinearRouterAdapter implements RouterPlatformAdapter {
readonly type = 'linear' as const;

async parseWebhook(payload: unknown): Promise<LinearParsedEvent | null> {
const p = payload as LinearWebhookPayload;

if (!p.action || !p.type || !p.data) {
logger.warn('LinearRouterAdapter: missing required fields', { payload });
return null;
}

if (!PROCESSABLE_TYPES.includes(p.type as ProcessableType)) {
logger.debug('LinearRouterAdapter: ignoring non-processable type', { type: p.type });
return null;
}

// Extract teamId from payload data for project lookup
const data = p.data as Record<string, unknown>;
const teamId = data.teamId as string | undefined;

if (!teamId) {
logger.debug('LinearRouterAdapter: no teamId in payload data, skipping');
return null;
}

const config = await loadProjectConfig();
const project = config.projects.find((proj) => proj.linear?.teamId === teamId);
if (!project) {
logger.debug('LinearRouterAdapter: no project found for teamId', { teamId });
return null;
}

const isCommentEvent = p.type === 'Comment';
const workItemId = isCommentEvent
? (data.issueId as string | undefined)
: (data.id as string | undefined);

return {
projectIdentifier: teamId,
eventType: `${p.action}/${p.type}`,
workItemId,
isCommentEvent,
projectId: project.id,
action: p.action,
resourceType: p.type,
};
}

isProcessableEvent(event: ParsedWebhookEvent): boolean {
// All parsed events are processable (we filter in parseWebhook)
return PROCESSABLE_TYPES.some((t) => event.eventType.endsWith(`/${t}`));
}

async isSelfAuthored(event: ParsedWebhookEvent, payload: unknown): Promise<boolean> {
if (!event.isCommentEvent) return false;
const data = (payload as Record<string, unknown>)?.data as Record<string, unknown> | undefined;
const commentAuthorId = data?.userId as string | undefined;
if (!commentAuthorId) return false;
try {
const projectId = (event as LinearParsedEvent).projectId;
const botId = await resolveLinearBotUserId(projectId);
return !!botId && commentAuthorId === botId;
} catch {
return false;
}
}

sendReaction(_event: ParsedWebhookEvent, _payload: unknown): void {
// Linear does not support emoji reactions on comments via the same API pattern.
// No-op for now.
}

async resolveProject(event: ParsedWebhookEvent): Promise<RouterProjectConfig | null> {
const config = await loadProjectConfig();
return config.projects.find((p) => p.linear?.teamId === event.projectIdentifier) ?? null;
}

async dispatchWithCredentials(
_event: ParsedWebhookEvent,
payload: unknown,
project: RouterProjectConfig,
triggerRegistry: TriggerRegistry,
): Promise<TriggerResult | null> {
const config = await loadProjectConfig();
const fullProject = config.fullProjects.find((fp) => fp.id === project.id);
if (!fullProject) {
logger.info('LinearRouterAdapter: no full project config found', {
projectId: project.id,
});
return null;
}

const linearCreds = await resolveLinearCredentials(project.id);
if (!linearCreds) {
logger.warn('LinearRouterAdapter: missing Linear credentials, cannot dispatch triggers', {
projectId: project.id,
});
return null;
}

const ctx: TriggerContext = { project: fullProject, source: 'linear', payload };
return withLinearCredentials({ apiKey: linearCreds.apiKey }, () =>
triggerRegistry.dispatch(ctx),
);
}

async postAck(
event: ParsedWebhookEvent,
payload: unknown,
project: RouterProjectConfig,
agentType: string,
_triggerResult?: TriggerResult,
): Promise<AckResult | undefined> {
const linearEvent = event as LinearParsedEvent;
const issueId = linearEvent.workItemId;
if (!issueId) return undefined;

try {
const context = extractLinearContext(payload);
let message = await generateAckMessage(agentType, context, project.id);

// Append run link footer when enabled for this project
const config = await loadProjectConfig();
const fullProject = config.fullProjects.find((fp) => fp.id === project.id);
if (fullProject?.runLinksEnabled && event.workItemId) {
const dashboardUrl = getDashboardUrl();
if (dashboardUrl) {
const link = buildWorkItemRunsLink({
dashboardUrl,
projectId: project.id,
workItemId: event.workItemId,
});
if (link) message += link;
}
}

const commentId = await postLinearAck(project.id, issueId, message);
if (commentId) return { commentId, message };
return undefined;
} catch (err) {
logger.warn('LinearRouterAdapter: ack comment failed (non-fatal)', {
error: String(err),
issueId,
});
return undefined;
}
}

buildJob(
event: ParsedWebhookEvent,
payload: unknown,
project: RouterProjectConfig,
result: TriggerResult,
ackResult?: AckResult,
): CascadeJob {
const linearEvent = event as LinearParsedEvent;
const job: LinearJob = {
type: 'linear',
source: 'linear',
payload,
projectId: project.id,
workItemId: linearEvent.workItemId,
eventType: linearEvent.eventType,
receivedAt: new Date().toISOString(),
triggerResult: result,
ackCommentId: ackResult?.commentId as string | undefined,
};
return job;
}
}
42 changes: 41 additions & 1 deletion src/router/bot-identity-resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
*/

import { BotIdentityCache } from './bot-identity.js';
import { resolveJiraCredentials, resolveTrelloCredentials } from './platformClients/index.js';
import {
resolveJiraCredentials,
resolveLinearCredentials,
resolveTrelloCredentials,
} from './platformClients/index.js';

// ---------------------------------------------------------------------------
// JIRA bot identity
Expand Down Expand Up @@ -70,3 +74,39 @@ export async function resolveTrelloBotMemberId(projectId: string): Promise<strin
export function _resetTrelloBotCache(): void {
trelloBotIdentityCache._reset();
}

// ---------------------------------------------------------------------------
// Linear bot identity
// ---------------------------------------------------------------------------

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.
* 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 creds = await resolveLinearCredentials(projectId);
if (!creds) return null;

const response = await fetch('https://api.linear.app/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${creds.apiKey}`,
},
body: JSON.stringify({ query: '{ viewer { id } }' }),
});
if (!response.ok) return null;

const data = (await response.json()) as { data?: { viewer?: { id?: string } } };
return data.data?.viewer?.id ?? null;
});
}

/** @internal Visible for testing only */
export function _resetLinearBotCache(): void {
linearBotIdentityCache._reset();
}
Loading
Loading