@chat-adapter/linear 4.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,727 @@
1
+ // src/index.ts
2
+ import { createHmac, timingSafeEqual } from "crypto";
3
+ import { extractCard, ValidationError } from "@chat-adapter/shared";
4
+ import { LinearClient } from "@linear/sdk";
5
+ import { convertEmojiPlaceholders, Message } from "chat";
6
+
7
+ // src/cards.ts
8
+ function cardToLinearMarkdown(card) {
9
+ const lines = [];
10
+ if (card.title) {
11
+ lines.push(`**${escapeMarkdown(card.title)}**`);
12
+ }
13
+ if (card.subtitle) {
14
+ lines.push(escapeMarkdown(card.subtitle));
15
+ }
16
+ if ((card.title || card.subtitle) && card.children.length > 0) {
17
+ lines.push("");
18
+ }
19
+ if (card.imageUrl) {
20
+ lines.push(`![](${card.imageUrl})`);
21
+ lines.push("");
22
+ }
23
+ for (let i = 0; i < card.children.length; i++) {
24
+ const child = card.children[i];
25
+ const childLines = renderChild(child);
26
+ if (childLines.length > 0) {
27
+ lines.push(...childLines);
28
+ if (i < card.children.length - 1) {
29
+ lines.push("");
30
+ }
31
+ }
32
+ }
33
+ return lines.join("\n");
34
+ }
35
+ function renderChild(child) {
36
+ switch (child.type) {
37
+ case "text":
38
+ return renderText(child);
39
+ case "fields":
40
+ return renderFields(child);
41
+ case "actions":
42
+ return renderActions(child);
43
+ case "section":
44
+ return child.children.flatMap(renderChild);
45
+ case "image":
46
+ if (child.alt) {
47
+ return [`![${escapeMarkdown(child.alt)}](${child.url})`];
48
+ }
49
+ return [`![](${child.url})`];
50
+ case "divider":
51
+ return ["---"];
52
+ default:
53
+ return [];
54
+ }
55
+ }
56
+ function renderText(text) {
57
+ const content = text.content;
58
+ switch (text.style) {
59
+ case "bold":
60
+ return [`**${content}**`];
61
+ case "muted":
62
+ return [`_${content}_`];
63
+ default:
64
+ return [content];
65
+ }
66
+ }
67
+ function renderFields(fields) {
68
+ return fields.children.map(
69
+ (field) => `**${escapeMarkdown(field.label)}:** ${escapeMarkdown(field.value)}`
70
+ );
71
+ }
72
+ function renderActions(actions) {
73
+ const buttonTexts = actions.children.map((button) => {
74
+ if (button.type === "link-button") {
75
+ return `[${escapeMarkdown(button.label)}](${button.url})`;
76
+ }
77
+ return `**[${escapeMarkdown(button.label)}]**`;
78
+ });
79
+ return [buttonTexts.join(" \u2022 ")];
80
+ }
81
+ function escapeMarkdown(text) {
82
+ return text.replace(/\*/g, "\\*").replace(/_/g, "\\_").replace(/\[/g, "\\[").replace(/\]/g, "\\]");
83
+ }
84
+
85
+ // src/markdown.ts
86
+ import {
87
+ BaseFormatConverter,
88
+ parseMarkdown,
89
+ stringifyMarkdown
90
+ } from "chat";
91
+ var LinearFormatConverter = class extends BaseFormatConverter {
92
+ /**
93
+ * Convert an AST to standard markdown.
94
+ * Linear uses standard markdown, so we use remark-stringify directly.
95
+ */
96
+ fromAst(ast) {
97
+ return stringifyMarkdown(ast).trim();
98
+ }
99
+ /**
100
+ * Parse markdown into an AST.
101
+ * Linear uses standard markdown, so we use the standard parser.
102
+ */
103
+ toAst(markdown) {
104
+ return parseMarkdown(markdown);
105
+ }
106
+ /**
107
+ * Render a postable message to Linear markdown string.
108
+ */
109
+ renderPostable(message) {
110
+ if (typeof message === "string") {
111
+ return message;
112
+ }
113
+ if ("raw" in message) {
114
+ return message.raw;
115
+ }
116
+ if ("markdown" in message) {
117
+ return this.fromMarkdown(message.markdown);
118
+ }
119
+ if ("ast" in message) {
120
+ return this.fromAst(message.ast);
121
+ }
122
+ return super.renderPostable(message);
123
+ }
124
+ };
125
+
126
+ // src/index.ts
127
+ var LinearAdapter = class {
128
+ name = "linear";
129
+ userName;
130
+ linearClient;
131
+ webhookSecret;
132
+ chat = null;
133
+ logger;
134
+ _botUserId = null;
135
+ formatConverter = new LinearFormatConverter();
136
+ // Client credentials auth state
137
+ clientCredentials = null;
138
+ accessTokenExpiry = null;
139
+ /** Bot user ID used for self-message detection */
140
+ get botUserId() {
141
+ return this._botUserId ?? void 0;
142
+ }
143
+ constructor(config) {
144
+ this.webhookSecret = config.webhookSecret;
145
+ this.logger = config.logger;
146
+ this.userName = config.userName;
147
+ if ("apiKey" in config && config.apiKey) {
148
+ this.linearClient = new LinearClient({ apiKey: config.apiKey });
149
+ } else if ("accessToken" in config && config.accessToken) {
150
+ this.linearClient = new LinearClient({
151
+ accessToken: config.accessToken
152
+ });
153
+ } else if ("clientId" in config && config.clientId) {
154
+ this.clientCredentials = {
155
+ clientId: config.clientId,
156
+ clientSecret: config.clientSecret
157
+ };
158
+ } else {
159
+ throw new Error(
160
+ "LinearAdapter requires either apiKey, accessToken, or clientId/clientSecret"
161
+ );
162
+ }
163
+ }
164
+ async initialize(chat) {
165
+ this.chat = chat;
166
+ if (this.clientCredentials) {
167
+ await this.refreshClientCredentialsToken();
168
+ }
169
+ try {
170
+ const viewer = await this.linearClient.viewer;
171
+ this._botUserId = viewer.id;
172
+ this.logger.info("Linear auth completed", {
173
+ botUserId: this._botUserId,
174
+ displayName: viewer.displayName
175
+ });
176
+ } catch (error) {
177
+ this.logger.warn("Could not fetch Linear bot user ID", { error });
178
+ }
179
+ }
180
+ /**
181
+ * Fetch a new access token using client credentials grant.
182
+ * The token is valid for 30 days. The adapter auto-refreshes on 401.
183
+ *
184
+ * @see https://linear.app/developers/oauth-2-0-authentication#client-credentials-tokens
185
+ */
186
+ async refreshClientCredentialsToken() {
187
+ if (!this.clientCredentials) return;
188
+ const { clientId, clientSecret } = this.clientCredentials;
189
+ const response = await fetch("https://api.linear.app/oauth/token", {
190
+ method: "POST",
191
+ headers: {
192
+ "Content-Type": "application/x-www-form-urlencoded"
193
+ },
194
+ body: new URLSearchParams({
195
+ grant_type: "client_credentials",
196
+ client_id: clientId,
197
+ client_secret: clientSecret,
198
+ scope: "read,write,comments:create,issues:create"
199
+ })
200
+ });
201
+ if (!response.ok) {
202
+ const errorBody = await response.text();
203
+ throw new Error(
204
+ `Failed to fetch Linear client credentials token: ${response.status} ${errorBody}`
205
+ );
206
+ }
207
+ const data = await response.json();
208
+ this.linearClient = new LinearClient({
209
+ accessToken: data.access_token
210
+ });
211
+ this.accessTokenExpiry = Date.now() + data.expires_in * 1e3 - 36e5;
212
+ this.logger.info("Linear client credentials token obtained", {
213
+ expiresIn: `${Math.round(data.expires_in / 86400)} days`
214
+ });
215
+ }
216
+ /**
217
+ * Ensure the client credentials token is still valid. Refresh if expired.
218
+ */
219
+ async ensureValidToken() {
220
+ if (this.clientCredentials && this.accessTokenExpiry && Date.now() > this.accessTokenExpiry) {
221
+ this.logger.info("Linear access token expired, refreshing...");
222
+ await this.refreshClientCredentialsToken();
223
+ }
224
+ }
225
+ /**
226
+ * Handle incoming webhook from Linear.
227
+ *
228
+ * @see https://linear.app/developers/webhooks
229
+ */
230
+ async handleWebhook(request, options) {
231
+ const body = await request.text();
232
+ this.logger.debug("Linear webhook raw body", {
233
+ body: body.substring(0, 500)
234
+ });
235
+ const signature = request.headers.get("linear-signature");
236
+ if (!this.verifySignature(body, signature)) {
237
+ return new Response("Invalid signature", { status: 401 });
238
+ }
239
+ let payload;
240
+ try {
241
+ payload = JSON.parse(body);
242
+ } catch {
243
+ this.logger.error("Linear webhook invalid JSON", {
244
+ contentType: request.headers.get("content-type"),
245
+ bodyPreview: body.substring(0, 200)
246
+ });
247
+ return new Response("Invalid JSON", { status: 400 });
248
+ }
249
+ if (payload.webhookTimestamp) {
250
+ const timeDiff = Math.abs(Date.now() - payload.webhookTimestamp);
251
+ if (timeDiff > 5 * 60 * 1e3) {
252
+ this.logger.warn("Linear webhook timestamp too old", {
253
+ webhookTimestamp: payload.webhookTimestamp,
254
+ timeDiff
255
+ });
256
+ return new Response("Webhook expired", { status: 401 });
257
+ }
258
+ }
259
+ if (payload.type === "Comment") {
260
+ const commentPayload = payload;
261
+ if (commentPayload.action === "create") {
262
+ this.handleCommentCreated(commentPayload, options);
263
+ }
264
+ } else if (payload.type === "Reaction") {
265
+ const reactionPayload = payload;
266
+ this.handleReaction(reactionPayload);
267
+ }
268
+ return new Response("ok", { status: 200 });
269
+ }
270
+ /**
271
+ * Verify Linear webhook signature using HMAC-SHA256.
272
+ *
273
+ * @see https://linear.app/developers/webhooks#securing-webhooks
274
+ */
275
+ verifySignature(body, signature) {
276
+ if (!signature) {
277
+ return false;
278
+ }
279
+ const computedSignature = createHmac("sha256", this.webhookSecret).update(body).digest("hex");
280
+ try {
281
+ return timingSafeEqual(
282
+ Buffer.from(computedSignature, "hex"),
283
+ Buffer.from(signature, "hex")
284
+ );
285
+ } catch {
286
+ return false;
287
+ }
288
+ }
289
+ /**
290
+ * Handle a new comment created on an issue.
291
+ *
292
+ * Threading logic:
293
+ * - If the comment has a parentId, it's a reply -> thread under the parent (root comment)
294
+ * - If no parentId, this is a root comment -> thread under this comment's own ID
295
+ */
296
+ handleCommentCreated(payload, options) {
297
+ if (!this.chat) {
298
+ this.logger.warn("Chat instance not initialized, ignoring comment");
299
+ return;
300
+ }
301
+ const { data, actor } = payload;
302
+ if (!data.issueId) {
303
+ this.logger.debug("Ignoring non-issue comment", {
304
+ commentId: data.id
305
+ });
306
+ return;
307
+ }
308
+ const rootCommentId = data.parentId || data.id;
309
+ const threadId = this.encodeThreadId({
310
+ issueId: data.issueId,
311
+ commentId: rootCommentId
312
+ });
313
+ const message = this.buildMessage(data, actor, threadId);
314
+ if (data.userId === this._botUserId) {
315
+ this.logger.debug("Ignoring message from self", {
316
+ messageId: data.id
317
+ });
318
+ return;
319
+ }
320
+ this.chat.processMessage(this, threadId, message, options);
321
+ }
322
+ /**
323
+ * Handle reaction events (logging only - reactions don't include issueId).
324
+ */
325
+ handleReaction(payload) {
326
+ if (!this.chat) {
327
+ return;
328
+ }
329
+ const { data, actor } = payload;
330
+ this.logger.debug("Received reaction webhook", {
331
+ reactionId: data.id,
332
+ emoji: data.emoji,
333
+ commentId: data.commentId,
334
+ action: payload.action,
335
+ actorName: actor.name
336
+ });
337
+ }
338
+ /**
339
+ * Build a Message from a Linear comment and actor.
340
+ */
341
+ buildMessage(comment, actor, threadId) {
342
+ const text = comment.body || "";
343
+ const author = {
344
+ userId: comment.userId,
345
+ userName: actor.name || "unknown",
346
+ fullName: actor.name || "unknown",
347
+ isBot: actor.type !== "user",
348
+ isMe: comment.userId === this._botUserId
349
+ };
350
+ const formatted = this.formatConverter.toAst(text);
351
+ const raw = {
352
+ comment,
353
+ organizationId: void 0
354
+ };
355
+ return new Message({
356
+ id: comment.id,
357
+ threadId,
358
+ text,
359
+ formatted,
360
+ raw,
361
+ author,
362
+ metadata: {
363
+ dateSent: comment.createdAt ? new Date(comment.createdAt) : /* @__PURE__ */ new Date(),
364
+ edited: comment.createdAt !== comment.updatedAt,
365
+ editedAt: comment.createdAt !== comment.updatedAt && comment.updatedAt ? new Date(comment.updatedAt) : void 0
366
+ },
367
+ attachments: []
368
+ });
369
+ }
370
+ /**
371
+ * Post a message to a thread (create a comment on an issue).
372
+ *
373
+ * For comment-level threads, uses parentId to reply under the root comment.
374
+ * For issue-level threads, creates a top-level comment.
375
+ *
376
+ * Uses LinearClient.createComment({ issueId, body, parentId? }).
377
+ * @see https://linear.app/developers/sdk-fetching-and-modifying-data#mutations
378
+ */
379
+ async postMessage(threadId, message) {
380
+ await this.ensureValidToken();
381
+ const { issueId, commentId } = this.decodeThreadId(threadId);
382
+ let body;
383
+ const card = extractCard(message);
384
+ if (card) {
385
+ body = cardToLinearMarkdown(card);
386
+ } else {
387
+ body = this.formatConverter.renderPostable(message);
388
+ }
389
+ body = convertEmojiPlaceholders(body, "linear");
390
+ const commentPayload = await this.linearClient.createComment({
391
+ issueId,
392
+ body,
393
+ parentId: commentId
394
+ });
395
+ const comment = await commentPayload.comment;
396
+ if (!comment) {
397
+ throw new Error("Failed to create comment on Linear issue");
398
+ }
399
+ return {
400
+ id: comment.id,
401
+ threadId,
402
+ raw: {
403
+ comment: {
404
+ id: comment.id,
405
+ body: comment.body,
406
+ issueId,
407
+ userId: this._botUserId || "",
408
+ createdAt: comment.createdAt.toISOString(),
409
+ updatedAt: comment.updatedAt.toISOString(),
410
+ url: comment.url
411
+ }
412
+ }
413
+ };
414
+ }
415
+ /**
416
+ * Edit an existing message (update a comment).
417
+ *
418
+ * Uses LinearClient.updateComment(id, { body }).
419
+ * @see https://linear.app/developers/sdk-fetching-and-modifying-data#mutations
420
+ */
421
+ async editMessage(threadId, messageId, message) {
422
+ await this.ensureValidToken();
423
+ const { issueId } = this.decodeThreadId(threadId);
424
+ let body;
425
+ const card = extractCard(message);
426
+ if (card) {
427
+ body = cardToLinearMarkdown(card);
428
+ } else {
429
+ body = this.formatConverter.renderPostable(message);
430
+ }
431
+ body = convertEmojiPlaceholders(body, "linear");
432
+ const commentPayload = await this.linearClient.updateComment(messageId, {
433
+ body
434
+ });
435
+ const comment = await commentPayload.comment;
436
+ if (!comment) {
437
+ throw new Error("Failed to update comment on Linear");
438
+ }
439
+ return {
440
+ id: comment.id,
441
+ threadId,
442
+ raw: {
443
+ comment: {
444
+ id: comment.id,
445
+ body: comment.body,
446
+ issueId,
447
+ userId: this._botUserId || "",
448
+ createdAt: comment.createdAt.toISOString(),
449
+ updatedAt: comment.updatedAt.toISOString(),
450
+ url: comment.url
451
+ }
452
+ }
453
+ };
454
+ }
455
+ /**
456
+ * Delete a message (delete a comment).
457
+ *
458
+ * Uses LinearClient.deleteComment(id).
459
+ */
460
+ async deleteMessage(_threadId, messageId) {
461
+ await this.ensureValidToken();
462
+ await this.linearClient.deleteComment(messageId);
463
+ }
464
+ /**
465
+ * Add a reaction to a comment.
466
+ *
467
+ * Uses LinearClient.createReaction({ commentId, emoji }).
468
+ * Linear reactions use emoji strings (unicode).
469
+ */
470
+ async addReaction(_threadId, messageId, emoji) {
471
+ await this.ensureValidToken();
472
+ const emojiStr = this.resolveEmoji(emoji);
473
+ await this.linearClient.createReaction({
474
+ commentId: messageId,
475
+ emoji: emojiStr
476
+ });
477
+ }
478
+ /**
479
+ * Remove a reaction from a comment.
480
+ *
481
+ * Linear doesn't have a direct "remove reaction by emoji + user" API.
482
+ * Removing requires knowing the reaction ID, which would require fetching
483
+ * the comment's reactions first. This is a known limitation.
484
+ */
485
+ async removeReaction(_threadId, _messageId, _emoji) {
486
+ this.logger.warn(
487
+ "removeReaction is not fully supported on Linear - reaction ID lookup would be required"
488
+ );
489
+ }
490
+ /**
491
+ * Start typing indicator. Not supported by Linear.
492
+ */
493
+ async startTyping(_threadId) {
494
+ }
495
+ /**
496
+ * Fetch messages from a thread.
497
+ *
498
+ * For issue-level threads: fetches all top-level issue comments.
499
+ * For comment-level threads: fetches the root comment and its children (replies).
500
+ */
501
+ async fetchMessages(threadId, options) {
502
+ await this.ensureValidToken();
503
+ const { issueId, commentId } = this.decodeThreadId(threadId);
504
+ if (commentId) {
505
+ return this.fetchCommentThread(threadId, issueId, commentId, options);
506
+ }
507
+ return this.fetchIssueComments(threadId, issueId, options);
508
+ }
509
+ /**
510
+ * Fetch top-level comments on an issue.
511
+ */
512
+ async fetchIssueComments(threadId, issueId, options) {
513
+ const issue = await this.linearClient.issue(issueId);
514
+ const commentsConnection = await issue.comments({
515
+ first: options?.limit ?? 50
516
+ });
517
+ const messages = await this.commentsToMessages(
518
+ commentsConnection.nodes,
519
+ threadId,
520
+ issueId
521
+ );
522
+ return {
523
+ messages,
524
+ nextCursor: commentsConnection.pageInfo.hasNextPage ? commentsConnection.pageInfo.endCursor : void 0
525
+ };
526
+ }
527
+ /**
528
+ * Fetch a comment thread (root comment + its children/replies).
529
+ */
530
+ async fetchCommentThread(threadId, issueId, commentId, options) {
531
+ const rootComment = await this.linearClient.comment({ id: commentId });
532
+ if (!rootComment) {
533
+ return { messages: [] };
534
+ }
535
+ const childrenConnection = await rootComment.children({
536
+ first: options?.limit ?? 50
537
+ });
538
+ const rootMessages = await this.commentsToMessages(
539
+ [rootComment],
540
+ threadId,
541
+ issueId
542
+ );
543
+ const childMessages = await this.commentsToMessages(
544
+ childrenConnection.nodes,
545
+ threadId,
546
+ issueId
547
+ );
548
+ return {
549
+ messages: [...rootMessages, ...childMessages],
550
+ nextCursor: childrenConnection.pageInfo.hasNextPage ? childrenConnection.pageInfo.endCursor : void 0
551
+ };
552
+ }
553
+ /**
554
+ * Convert an array of Linear SDK Comment objects to Message instances.
555
+ */
556
+ async commentsToMessages(comments, threadId, issueId) {
557
+ const messages = [];
558
+ for (const comment of comments) {
559
+ const user = await comment.user;
560
+ const author = {
561
+ userId: user?.id || "unknown",
562
+ userName: user?.displayName || "unknown",
563
+ fullName: user?.name || user?.displayName || "unknown",
564
+ isBot: false,
565
+ isMe: user?.id === this._botUserId
566
+ };
567
+ const formatted = this.formatConverter.toAst(
568
+ comment.body
569
+ );
570
+ messages.push(
571
+ new Message({
572
+ id: comment.id,
573
+ threadId,
574
+ text: comment.body,
575
+ formatted,
576
+ author,
577
+ metadata: {
578
+ dateSent: new Date(comment.createdAt),
579
+ edited: comment.createdAt.getTime() !== comment.updatedAt.getTime(),
580
+ editedAt: comment.createdAt.getTime() !== comment.updatedAt.getTime() ? new Date(comment.updatedAt) : void 0
581
+ },
582
+ attachments: [],
583
+ raw: {
584
+ comment: {
585
+ id: comment.id,
586
+ body: comment.body,
587
+ issueId,
588
+ userId: user?.id || "unknown",
589
+ createdAt: comment.createdAt.toISOString(),
590
+ updatedAt: comment.updatedAt.toISOString(),
591
+ url: comment.url
592
+ }
593
+ }
594
+ })
595
+ );
596
+ }
597
+ return messages;
598
+ }
599
+ /**
600
+ * Fetch thread info for a Linear issue.
601
+ */
602
+ async fetchThread(threadId) {
603
+ await this.ensureValidToken();
604
+ const { issueId } = this.decodeThreadId(threadId);
605
+ const issue = await this.linearClient.issue(issueId);
606
+ return {
607
+ id: threadId,
608
+ channelId: issueId,
609
+ channelName: `${issue.identifier}: ${issue.title}`,
610
+ isDM: false,
611
+ metadata: {
612
+ issueId,
613
+ identifier: issue.identifier,
614
+ title: issue.title,
615
+ url: issue.url
616
+ }
617
+ };
618
+ }
619
+ /**
620
+ * Encode a Linear thread ID.
621
+ *
622
+ * Formats:
623
+ * - Issue-level: linear:{issueId}
624
+ * - Comment thread: linear:{issueId}:c:{commentId}
625
+ */
626
+ encodeThreadId(platformData) {
627
+ if (platformData.commentId) {
628
+ return `linear:${platformData.issueId}:c:${platformData.commentId}`;
629
+ }
630
+ return `linear:${platformData.issueId}`;
631
+ }
632
+ /**
633
+ * Decode a Linear thread ID.
634
+ *
635
+ * Formats:
636
+ * - Issue-level: linear:{issueId}
637
+ * - Comment thread: linear:{issueId}:c:{commentId}
638
+ */
639
+ decodeThreadId(threadId) {
640
+ if (!threadId.startsWith("linear:")) {
641
+ throw new ValidationError(
642
+ "linear",
643
+ `Invalid Linear thread ID: ${threadId}`
644
+ );
645
+ }
646
+ const withoutPrefix = threadId.slice(7);
647
+ if (!withoutPrefix) {
648
+ throw new ValidationError(
649
+ "linear",
650
+ `Invalid Linear thread ID format: ${threadId}`
651
+ );
652
+ }
653
+ const commentMatch = withoutPrefix.match(/^([^:]+):c:([^:]+)$/);
654
+ if (commentMatch) {
655
+ return {
656
+ issueId: commentMatch[1],
657
+ commentId: commentMatch[2]
658
+ };
659
+ }
660
+ return { issueId: withoutPrefix };
661
+ }
662
+ /**
663
+ * Parse platform message format to normalized format.
664
+ */
665
+ parseMessage(raw) {
666
+ const text = raw.comment.body || "";
667
+ const formatted = this.formatConverter.toAst(text);
668
+ return new Message({
669
+ id: raw.comment.id,
670
+ threadId: "",
671
+ text,
672
+ formatted,
673
+ author: {
674
+ userId: raw.comment.userId,
675
+ userName: "unknown",
676
+ fullName: "unknown",
677
+ isBot: false,
678
+ isMe: raw.comment.userId === this._botUserId
679
+ },
680
+ metadata: {
681
+ dateSent: raw.comment.createdAt ? new Date(raw.comment.createdAt) : /* @__PURE__ */ new Date(),
682
+ edited: raw.comment.createdAt !== raw.comment.updatedAt,
683
+ editedAt: raw.comment.createdAt !== raw.comment.updatedAt && raw.comment.updatedAt ? new Date(raw.comment.updatedAt) : void 0
684
+ },
685
+ attachments: [],
686
+ raw
687
+ });
688
+ }
689
+ /**
690
+ * Render formatted content to Linear markdown.
691
+ */
692
+ renderFormatted(content) {
693
+ return this.formatConverter.fromAst(content);
694
+ }
695
+ /**
696
+ * Resolve an emoji value to a unicode string.
697
+ * Linear uses standard unicode emoji for reactions.
698
+ */
699
+ resolveEmoji(emoji) {
700
+ const emojiName = typeof emoji === "string" ? emoji : emoji.name;
701
+ const mapping = {
702
+ thumbs_up: "\u{1F44D}",
703
+ thumbs_down: "\u{1F44E}",
704
+ heart: "\u2764\uFE0F",
705
+ fire: "\u{1F525}",
706
+ rocket: "\u{1F680}",
707
+ eyes: "\u{1F440}",
708
+ check: "\u2705",
709
+ warning: "\u26A0\uFE0F",
710
+ sparkles: "\u2728",
711
+ wave: "\u{1F44B}",
712
+ raised_hands: "\u{1F64C}",
713
+ laugh: "\u{1F604}",
714
+ hooray: "\u{1F389}",
715
+ confused: "\u{1F615}"
716
+ };
717
+ return mapping[emojiName] || emojiName;
718
+ }
719
+ };
720
+ function createLinearAdapter(config) {
721
+ return new LinearAdapter(config);
722
+ }
723
+ export {
724
+ LinearAdapter,
725
+ createLinearAdapter
726
+ };
727
+ //# sourceMappingURL=index.js.map