@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/LICENSE +9 -0
- package/README.md +254 -0
- package/dist/index.d.ts +334 -0
- package/dist/index.js +727 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
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(``);
|
|
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 [``];
|
|
48
|
+
}
|
|
49
|
+
return [``];
|
|
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
|