@chat-adapter/github 0.0.1

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,885 @@
1
+ // src/index.ts
2
+ import { createHmac, timingSafeEqual } from "crypto";
3
+ import { extractCard, ValidationError } from "@chat-adapter/shared";
4
+ import { createAppAuth } from "@octokit/auth-app";
5
+ import { Octokit } from "@octokit/rest";
6
+ import { convertEmojiPlaceholders, Message } from "chat";
7
+
8
+ // src/cards.ts
9
+ function cardToGitHubMarkdown(card) {
10
+ const lines = [];
11
+ if (card.title) {
12
+ lines.push(`**${escapeMarkdown(card.title)}**`);
13
+ }
14
+ if (card.subtitle) {
15
+ lines.push(escapeMarkdown(card.subtitle));
16
+ }
17
+ if ((card.title || card.subtitle) && card.children.length > 0) {
18
+ lines.push("");
19
+ }
20
+ if (card.imageUrl) {
21
+ lines.push(`![](${card.imageUrl})`);
22
+ lines.push("");
23
+ }
24
+ for (let i = 0; i < card.children.length; i++) {
25
+ const child = card.children[i];
26
+ const childLines = renderChild(child);
27
+ if (childLines.length > 0) {
28
+ lines.push(...childLines);
29
+ if (i < card.children.length - 1) {
30
+ lines.push("");
31
+ }
32
+ }
33
+ }
34
+ return lines.join("\n");
35
+ }
36
+ function renderChild(child) {
37
+ switch (child.type) {
38
+ case "text":
39
+ return renderText(child);
40
+ case "fields":
41
+ return renderFields(child);
42
+ case "actions":
43
+ return renderActions(child);
44
+ case "section":
45
+ return child.children.flatMap(renderChild);
46
+ case "image":
47
+ if (child.alt) {
48
+ return [`![${escapeMarkdown(child.alt)}](${child.url})`];
49
+ }
50
+ return [`![](${child.url})`];
51
+ case "divider":
52
+ return ["---"];
53
+ default:
54
+ return [];
55
+ }
56
+ }
57
+ function renderText(text) {
58
+ const content = text.content;
59
+ switch (text.style) {
60
+ case "bold":
61
+ return [`**${content}**`];
62
+ case "muted":
63
+ return [`_${content}_`];
64
+ default:
65
+ return [content];
66
+ }
67
+ }
68
+ function renderFields(fields) {
69
+ return fields.children.map(
70
+ (field) => `**${escapeMarkdown(field.label)}:** ${escapeMarkdown(field.value)}`
71
+ );
72
+ }
73
+ function renderActions(actions) {
74
+ const buttonTexts = actions.children.map((button) => {
75
+ if (button.type === "link-button") {
76
+ return `[${escapeMarkdown(button.label)}](${button.url})`;
77
+ }
78
+ return `**[${escapeMarkdown(button.label)}]**`;
79
+ });
80
+ return [buttonTexts.join(" \u2022 ")];
81
+ }
82
+ function escapeMarkdown(text) {
83
+ return text.replace(/\*/g, "\\*").replace(/_/g, "\\_").replace(/\[/g, "\\[").replace(/\]/g, "\\]");
84
+ }
85
+
86
+ // src/markdown.ts
87
+ import {
88
+ BaseFormatConverter,
89
+ parseMarkdown,
90
+ stringifyMarkdown
91
+ } from "chat";
92
+ var GitHubFormatConverter = class extends BaseFormatConverter {
93
+ /**
94
+ * GitHub uses standard GFM, so we can use remark-stringify directly.
95
+ * We just need to ensure @mentions are preserved.
96
+ */
97
+ fromAst(ast) {
98
+ return stringifyMarkdown(ast).trim();
99
+ }
100
+ /**
101
+ * Parse GitHub markdown into an AST.
102
+ * GitHub uses standard GFM, so we use the standard parser.
103
+ */
104
+ toAst(markdown) {
105
+ return parseMarkdown(markdown);
106
+ }
107
+ /**
108
+ * Override renderPostable to handle @mentions in plain strings.
109
+ * GitHub @mentions are already in the correct format (@username).
110
+ */
111
+ renderPostable(message) {
112
+ if (typeof message === "string") {
113
+ return message;
114
+ }
115
+ if ("raw" in message) {
116
+ return message.raw;
117
+ }
118
+ if ("markdown" in message) {
119
+ return this.fromMarkdown(message.markdown);
120
+ }
121
+ if ("ast" in message) {
122
+ return this.fromAst(message.ast);
123
+ }
124
+ return super.renderPostable(message);
125
+ }
126
+ };
127
+
128
+ // src/index.ts
129
+ var GitHubAdapter = class {
130
+ name = "github";
131
+ userName;
132
+ // Single Octokit instance for PAT or single-tenant app mode
133
+ octokit = null;
134
+ // App credentials for multi-tenant mode
135
+ appCredentials = null;
136
+ // Cache of Octokit instances per installation (for multi-tenant)
137
+ installationClients = /* @__PURE__ */ new Map();
138
+ webhookSecret;
139
+ chat = null;
140
+ logger;
141
+ _botUserId = null;
142
+ formatConverter = new GitHubFormatConverter();
143
+ /** Bot user ID (numeric) used for self-message detection */
144
+ get botUserId() {
145
+ return this._botUserId?.toString();
146
+ }
147
+ /** Whether this adapter is in multi-tenant mode (no fixed installation ID) */
148
+ get isMultiTenant() {
149
+ return this.appCredentials !== null && this.octokit === null;
150
+ }
151
+ constructor(config) {
152
+ this.webhookSecret = config.webhookSecret;
153
+ this.logger = config.logger;
154
+ this.userName = config.userName;
155
+ this._botUserId = config.botUserId ?? null;
156
+ if ("token" in config && config.token) {
157
+ this.octokit = new Octokit({ auth: config.token });
158
+ } else if ("appId" in config && config.appId) {
159
+ if ("installationId" in config && config.installationId) {
160
+ this.octokit = new Octokit({
161
+ authStrategy: createAppAuth,
162
+ auth: {
163
+ appId: config.appId,
164
+ privateKey: config.privateKey,
165
+ installationId: config.installationId
166
+ }
167
+ });
168
+ } else {
169
+ this.appCredentials = {
170
+ appId: config.appId,
171
+ privateKey: config.privateKey
172
+ };
173
+ this.logger.info(
174
+ "GitHub adapter initialized in multi-tenant mode (installation ID will be extracted from webhooks)"
175
+ );
176
+ }
177
+ } else {
178
+ throw new Error(
179
+ "GitHubAdapter requires either token or appId/privateKey"
180
+ );
181
+ }
182
+ }
183
+ /**
184
+ * Get or create an Octokit instance for a specific installation.
185
+ * For single-tenant mode, returns the single instance.
186
+ * For multi-tenant mode, creates/caches instances per installation.
187
+ */
188
+ getOctokit(installationId) {
189
+ if (this.octokit) {
190
+ return this.octokit;
191
+ }
192
+ if (!this.appCredentials) {
193
+ throw new Error("Adapter not properly configured");
194
+ }
195
+ if (!installationId) {
196
+ throw new Error(
197
+ "Installation ID required for multi-tenant mode. This usually means you're trying to make an API call outside of a webhook context. For proactive messages, use thread IDs from previous webhook interactions."
198
+ );
199
+ }
200
+ let client = this.installationClients.get(installationId);
201
+ if (!client) {
202
+ client = new Octokit({
203
+ authStrategy: createAppAuth,
204
+ auth: {
205
+ appId: this.appCredentials.appId,
206
+ privateKey: this.appCredentials.privateKey,
207
+ installationId
208
+ }
209
+ });
210
+ this.installationClients.set(installationId, client);
211
+ this.logger.debug("Created Octokit client for installation", {
212
+ installationId
213
+ });
214
+ }
215
+ return client;
216
+ }
217
+ async initialize(chat) {
218
+ this.chat = chat;
219
+ if (!this._botUserId && this.octokit) {
220
+ try {
221
+ const { data: user } = await this.octokit.users.getAuthenticated();
222
+ this._botUserId = user.id;
223
+ this.logger.info("GitHub auth completed", {
224
+ botUserId: this._botUserId,
225
+ login: user.login
226
+ });
227
+ } catch (error) {
228
+ this.logger.warn("Could not fetch bot user ID", { error });
229
+ }
230
+ }
231
+ }
232
+ /**
233
+ * Get the state key for storing installation ID for a repository.
234
+ */
235
+ getInstallationKey(owner, repo) {
236
+ return `github:install:${owner}/${repo}`;
237
+ }
238
+ /**
239
+ * Store the installation ID for a repository (for multi-tenant mode).
240
+ */
241
+ async storeInstallationId(owner, repo, installationId) {
242
+ if (!this.chat || !this.isMultiTenant) return;
243
+ const key = this.getInstallationKey(owner, repo);
244
+ await this.chat.getState().set(key, installationId);
245
+ this.logger.debug("Stored installation ID", {
246
+ owner,
247
+ repo,
248
+ installationId
249
+ });
250
+ }
251
+ /**
252
+ * Get the installation ID for a repository (for multi-tenant mode).
253
+ */
254
+ async getInstallationId(owner, repo) {
255
+ if (!this.chat || !this.isMultiTenant) return void 0;
256
+ const key = this.getInstallationKey(owner, repo);
257
+ return await this.chat.getState().get(key) ?? void 0;
258
+ }
259
+ /**
260
+ * Handle incoming webhook from GitHub.
261
+ */
262
+ async handleWebhook(request, options) {
263
+ const body = await request.text();
264
+ this.logger.debug("GitHub webhook raw body", {
265
+ body: body.substring(0, 500)
266
+ });
267
+ const signature = request.headers.get("x-hub-signature-256");
268
+ if (!this.verifySignature(body, signature)) {
269
+ return new Response("Invalid signature", { status: 401 });
270
+ }
271
+ const eventType = request.headers.get("x-github-event");
272
+ this.logger.debug("GitHub webhook event type", { eventType });
273
+ if (eventType === "ping") {
274
+ this.logger.info("GitHub webhook ping received");
275
+ return new Response("pong", { status: 200 });
276
+ }
277
+ let payload;
278
+ try {
279
+ payload = JSON.parse(body);
280
+ } catch {
281
+ this.logger.error("GitHub webhook invalid JSON", {
282
+ contentType: request.headers.get("content-type"),
283
+ bodyPreview: body.substring(0, 200)
284
+ });
285
+ return new Response(
286
+ "Invalid JSON. Make sure webhook Content-Type is set to application/json",
287
+ { status: 400 }
288
+ );
289
+ }
290
+ const installationId = payload.installation?.id;
291
+ if (installationId && this.isMultiTenant) {
292
+ const repo = payload.repository;
293
+ await this.storeInstallationId(
294
+ repo.owner.login,
295
+ repo.name,
296
+ installationId
297
+ );
298
+ }
299
+ if (eventType === "issue_comment") {
300
+ const issuePayload = payload;
301
+ if (issuePayload.action === "created" && issuePayload.issue.pull_request) {
302
+ this.handleIssueComment(issuePayload, installationId, options);
303
+ }
304
+ } else if (eventType === "pull_request_review_comment") {
305
+ const reviewPayload = payload;
306
+ if (reviewPayload.action === "created") {
307
+ this.handleReviewComment(reviewPayload, installationId, options);
308
+ }
309
+ }
310
+ return new Response("ok", { status: 200 });
311
+ }
312
+ /**
313
+ * Verify GitHub webhook signature using HMAC-SHA256.
314
+ */
315
+ verifySignature(body, signature) {
316
+ if (!signature) {
317
+ return false;
318
+ }
319
+ const expected = `sha256=${createHmac("sha256", this.webhookSecret).update(body).digest("hex")}`;
320
+ try {
321
+ return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
322
+ } catch {
323
+ return false;
324
+ }
325
+ }
326
+ /**
327
+ * Handle issue_comment webhook (PR-level comments in Conversation tab).
328
+ */
329
+ handleIssueComment(payload, _installationId, options) {
330
+ if (!this.chat) {
331
+ this.logger.warn("Chat instance not initialized, ignoring comment");
332
+ return;
333
+ }
334
+ const { comment, issue, repository, sender } = payload;
335
+ const threadId = this.encodeThreadId({
336
+ owner: repository.owner.login,
337
+ repo: repository.name,
338
+ prNumber: issue.number
339
+ });
340
+ const message = this.parseIssueComment(
341
+ comment,
342
+ repository,
343
+ issue.number,
344
+ threadId
345
+ );
346
+ if (sender.id === this._botUserId) {
347
+ this.logger.debug("Ignoring message from self", {
348
+ messageId: comment.id
349
+ });
350
+ return;
351
+ }
352
+ this.chat.processMessage(this, threadId, message, options);
353
+ }
354
+ /**
355
+ * Handle pull_request_review_comment webhook (line-specific comments).
356
+ */
357
+ handleReviewComment(payload, _installationId, options) {
358
+ if (!this.chat) {
359
+ this.logger.warn("Chat instance not initialized, ignoring comment");
360
+ return;
361
+ }
362
+ const { comment, pull_request, repository, sender } = payload;
363
+ const rootCommentId = comment.in_reply_to_id ?? comment.id;
364
+ const threadId = this.encodeThreadId({
365
+ owner: repository.owner.login,
366
+ repo: repository.name,
367
+ prNumber: pull_request.number,
368
+ reviewCommentId: rootCommentId
369
+ });
370
+ const message = this.parseReviewComment(
371
+ comment,
372
+ repository,
373
+ pull_request.number,
374
+ threadId
375
+ );
376
+ if (sender.id === this._botUserId) {
377
+ this.logger.debug("Ignoring message from self", {
378
+ messageId: comment.id
379
+ });
380
+ return;
381
+ }
382
+ this.chat.processMessage(this, threadId, message, options);
383
+ }
384
+ /**
385
+ * Parse an issue comment into a normalized Message.
386
+ */
387
+ parseIssueComment(comment, repository, prNumber, threadId) {
388
+ const author = this.parseAuthor(comment.user);
389
+ return new Message({
390
+ id: comment.id.toString(),
391
+ threadId,
392
+ text: this.formatConverter.extractPlainText(comment.body),
393
+ formatted: this.formatConverter.toAst(comment.body),
394
+ raw: {
395
+ type: "issue_comment",
396
+ comment,
397
+ repository: {
398
+ id: 0,
399
+ // Not needed for raw storage
400
+ name: repository.name,
401
+ full_name: `${repository.owner.login}/${repository.name}`,
402
+ owner: repository.owner
403
+ },
404
+ prNumber
405
+ },
406
+ author,
407
+ metadata: {
408
+ dateSent: new Date(comment.created_at),
409
+ edited: comment.created_at !== comment.updated_at,
410
+ editedAt: comment.created_at !== comment.updated_at ? new Date(comment.updated_at) : void 0
411
+ },
412
+ attachments: []
413
+ });
414
+ }
415
+ /**
416
+ * Parse a review comment into a normalized Message.
417
+ */
418
+ parseReviewComment(comment, repository, prNumber, threadId) {
419
+ const author = this.parseAuthor(comment.user);
420
+ return new Message({
421
+ id: comment.id.toString(),
422
+ threadId,
423
+ text: this.formatConverter.extractPlainText(comment.body),
424
+ formatted: this.formatConverter.toAst(comment.body),
425
+ raw: {
426
+ type: "review_comment",
427
+ comment,
428
+ repository: {
429
+ id: 0,
430
+ name: repository.name,
431
+ full_name: `${repository.owner.login}/${repository.name}`,
432
+ owner: repository.owner
433
+ },
434
+ prNumber
435
+ },
436
+ author,
437
+ metadata: {
438
+ dateSent: new Date(comment.created_at),
439
+ edited: comment.created_at !== comment.updated_at,
440
+ editedAt: comment.created_at !== comment.updated_at ? new Date(comment.updated_at) : void 0
441
+ },
442
+ attachments: []
443
+ });
444
+ }
445
+ /**
446
+ * Parse a GitHub user into an Author.
447
+ */
448
+ parseAuthor(user) {
449
+ return {
450
+ userId: user.id.toString(),
451
+ userName: user.login,
452
+ fullName: user.login,
453
+ // GitHub doesn't always expose real names
454
+ isBot: user.type === "Bot",
455
+ isMe: user.id === this._botUserId
456
+ };
457
+ }
458
+ /**
459
+ * Get the Octokit client for a specific thread.
460
+ * In multi-tenant mode, looks up the installation ID from state.
461
+ */
462
+ async getOctokitForThread(owner, repo) {
463
+ const installationId = await this.getInstallationId(owner, repo);
464
+ return this.getOctokit(installationId);
465
+ }
466
+ /**
467
+ * Post a message to a thread.
468
+ */
469
+ async postMessage(threadId, message) {
470
+ const { owner, repo, prNumber, reviewCommentId } = this.decodeThreadId(threadId);
471
+ const octokit = await this.getOctokitForThread(owner, repo);
472
+ let body;
473
+ const card = extractCard(message);
474
+ if (card) {
475
+ body = cardToGitHubMarkdown(card);
476
+ } else {
477
+ body = this.formatConverter.renderPostable(message);
478
+ }
479
+ body = convertEmojiPlaceholders(body, "github");
480
+ if (reviewCommentId) {
481
+ const { data: comment } = await octokit.pulls.createReplyForReviewComment(
482
+ {
483
+ owner,
484
+ repo,
485
+ pull_number: prNumber,
486
+ comment_id: reviewCommentId,
487
+ body
488
+ }
489
+ );
490
+ return {
491
+ id: comment.id.toString(),
492
+ threadId,
493
+ raw: {
494
+ type: "review_comment",
495
+ comment,
496
+ repository: {
497
+ id: 0,
498
+ name: repo,
499
+ full_name: `${owner}/${repo}`,
500
+ owner: { id: 0, login: owner, type: "User" }
501
+ },
502
+ prNumber
503
+ }
504
+ };
505
+ } else {
506
+ const { data: comment } = await octokit.issues.createComment({
507
+ owner,
508
+ repo,
509
+ issue_number: prNumber,
510
+ body
511
+ });
512
+ return {
513
+ id: comment.id.toString(),
514
+ threadId,
515
+ raw: {
516
+ type: "issue_comment",
517
+ comment,
518
+ repository: {
519
+ id: 0,
520
+ name: repo,
521
+ full_name: `${owner}/${repo}`,
522
+ owner: { id: 0, login: owner, type: "User" }
523
+ },
524
+ prNumber
525
+ }
526
+ };
527
+ }
528
+ }
529
+ /**
530
+ * Edit an existing message.
531
+ */
532
+ async editMessage(threadId, messageId, message) {
533
+ const { owner, repo, prNumber, reviewCommentId } = this.decodeThreadId(threadId);
534
+ const commentId = parseInt(messageId, 10);
535
+ const octokit = await this.getOctokitForThread(owner, repo);
536
+ let body;
537
+ const card = extractCard(message);
538
+ if (card) {
539
+ body = cardToGitHubMarkdown(card);
540
+ } else {
541
+ body = this.formatConverter.renderPostable(message);
542
+ }
543
+ body = convertEmojiPlaceholders(body, "github");
544
+ if (reviewCommentId) {
545
+ const { data: comment } = await octokit.pulls.updateReviewComment({
546
+ owner,
547
+ repo,
548
+ comment_id: commentId,
549
+ body
550
+ });
551
+ return {
552
+ id: comment.id.toString(),
553
+ threadId,
554
+ raw: {
555
+ type: "review_comment",
556
+ comment,
557
+ repository: {
558
+ id: 0,
559
+ name: repo,
560
+ full_name: `${owner}/${repo}`,
561
+ owner: { id: 0, login: owner, type: "User" }
562
+ },
563
+ prNumber
564
+ }
565
+ };
566
+ } else {
567
+ const { data: comment } = await octokit.issues.updateComment({
568
+ owner,
569
+ repo,
570
+ comment_id: commentId,
571
+ body
572
+ });
573
+ return {
574
+ id: comment.id.toString(),
575
+ threadId,
576
+ raw: {
577
+ type: "issue_comment",
578
+ comment,
579
+ repository: {
580
+ id: 0,
581
+ name: repo,
582
+ full_name: `${owner}/${repo}`,
583
+ owner: { id: 0, login: owner, type: "User" }
584
+ },
585
+ prNumber
586
+ }
587
+ };
588
+ }
589
+ }
590
+ /**
591
+ * Delete a message.
592
+ */
593
+ async deleteMessage(threadId, messageId) {
594
+ const { owner, repo, reviewCommentId } = this.decodeThreadId(threadId);
595
+ const commentId = parseInt(messageId, 10);
596
+ const octokit = await this.getOctokitForThread(owner, repo);
597
+ if (reviewCommentId) {
598
+ await octokit.pulls.deleteReviewComment({
599
+ owner,
600
+ repo,
601
+ comment_id: commentId
602
+ });
603
+ } else {
604
+ await octokit.issues.deleteComment({
605
+ owner,
606
+ repo,
607
+ comment_id: commentId
608
+ });
609
+ }
610
+ }
611
+ /**
612
+ * Add a reaction to a message.
613
+ */
614
+ async addReaction(threadId, messageId, emoji) {
615
+ const { owner, repo, reviewCommentId } = this.decodeThreadId(threadId);
616
+ const commentId = parseInt(messageId, 10);
617
+ const octokit = await this.getOctokitForThread(owner, repo);
618
+ const content = this.emojiToGitHubReaction(emoji);
619
+ if (reviewCommentId) {
620
+ await octokit.reactions.createForPullRequestReviewComment({
621
+ owner,
622
+ repo,
623
+ comment_id: commentId,
624
+ content
625
+ });
626
+ } else {
627
+ await octokit.reactions.createForIssueComment({
628
+ owner,
629
+ repo,
630
+ comment_id: commentId,
631
+ content
632
+ });
633
+ }
634
+ }
635
+ /**
636
+ * Remove a reaction from a message.
637
+ */
638
+ async removeReaction(threadId, messageId, emoji) {
639
+ const { owner, repo, reviewCommentId } = this.decodeThreadId(threadId);
640
+ const commentId = parseInt(messageId, 10);
641
+ const content = this.emojiToGitHubReaction(emoji);
642
+ const octokit = await this.getOctokitForThread(owner, repo);
643
+ const reactions = reviewCommentId ? (await octokit.reactions.listForPullRequestReviewComment({
644
+ owner,
645
+ repo,
646
+ comment_id: commentId
647
+ })).data : (await octokit.reactions.listForIssueComment({
648
+ owner,
649
+ repo,
650
+ comment_id: commentId
651
+ })).data;
652
+ const reaction = reactions.find(
653
+ (r) => r.content === content && r.user?.id === this._botUserId
654
+ );
655
+ if (reaction) {
656
+ if (reviewCommentId) {
657
+ await octokit.reactions.deleteForPullRequestComment({
658
+ owner,
659
+ repo,
660
+ comment_id: commentId,
661
+ reaction_id: reaction.id
662
+ });
663
+ } else {
664
+ await octokit.reactions.deleteForIssueComment({
665
+ owner,
666
+ repo,
667
+ comment_id: commentId,
668
+ reaction_id: reaction.id
669
+ });
670
+ }
671
+ }
672
+ }
673
+ /**
674
+ * Convert SDK emoji to GitHub reaction content.
675
+ */
676
+ emojiToGitHubReaction(emoji) {
677
+ const emojiName = typeof emoji === "string" ? emoji : emoji.name;
678
+ const mapping = {
679
+ thumbs_up: "+1",
680
+ "+1": "+1",
681
+ thumbs_down: "-1",
682
+ "-1": "-1",
683
+ laugh: "laugh",
684
+ smile: "laugh",
685
+ confused: "confused",
686
+ thinking: "confused",
687
+ heart: "heart",
688
+ love_eyes: "heart",
689
+ hooray: "hooray",
690
+ party: "hooray",
691
+ confetti: "hooray",
692
+ rocket: "rocket",
693
+ eyes: "eyes"
694
+ };
695
+ return mapping[emojiName] || "+1";
696
+ }
697
+ /**
698
+ * Show typing indicator (no-op for GitHub).
699
+ */
700
+ async startTyping(_threadId) {
701
+ }
702
+ /**
703
+ * Fetch messages from a thread.
704
+ */
705
+ async fetchMessages(threadId, options) {
706
+ const { owner, repo, prNumber, reviewCommentId } = this.decodeThreadId(threadId);
707
+ const limit = options?.limit ?? 100;
708
+ const direction = options?.direction ?? "backward";
709
+ const octokit = await this.getOctokitForThread(owner, repo);
710
+ let messages;
711
+ if (reviewCommentId) {
712
+ const { data: allComments } = await octokit.pulls.listReviewComments({
713
+ owner,
714
+ repo,
715
+ pull_number: prNumber,
716
+ per_page: 100
717
+ // Fetch more to filter
718
+ });
719
+ const threadComments = allComments.filter(
720
+ (c) => c.id === reviewCommentId || c.in_reply_to_id === reviewCommentId
721
+ );
722
+ messages = threadComments.map(
723
+ (comment) => this.parseReviewComment(
724
+ comment,
725
+ {
726
+ owner: { id: 0, login: owner, type: "User", avatar_url: "" },
727
+ name: repo
728
+ },
729
+ prNumber,
730
+ threadId
731
+ )
732
+ );
733
+ } else {
734
+ const { data: comments } = await octokit.issues.listComments({
735
+ owner,
736
+ repo,
737
+ issue_number: prNumber,
738
+ per_page: limit
739
+ });
740
+ messages = comments.map(
741
+ (comment) => this.parseIssueComment(
742
+ comment,
743
+ {
744
+ owner: { id: 0, login: owner, type: "User", avatar_url: "" },
745
+ name: repo
746
+ },
747
+ prNumber,
748
+ threadId
749
+ )
750
+ );
751
+ }
752
+ messages.sort(
753
+ (a, b) => a.metadata.dateSent.getTime() - b.metadata.dateSent.getTime()
754
+ );
755
+ if (direction === "backward" && messages.length > limit) {
756
+ messages = messages.slice(-limit);
757
+ } else if (direction === "forward" && messages.length > limit) {
758
+ messages = messages.slice(0, limit);
759
+ }
760
+ return {
761
+ messages,
762
+ nextCursor: void 0
763
+ // Simplified pagination for now
764
+ };
765
+ }
766
+ /**
767
+ * Fetch thread metadata.
768
+ */
769
+ async fetchThread(threadId) {
770
+ const { owner, repo, prNumber, reviewCommentId } = this.decodeThreadId(threadId);
771
+ const octokit = await this.getOctokitForThread(owner, repo);
772
+ const { data: pr } = await octokit.pulls.get({
773
+ owner,
774
+ repo,
775
+ pull_number: prNumber
776
+ });
777
+ return {
778
+ id: threadId,
779
+ channelId: `${owner}/${repo}`,
780
+ channelName: `${repo} #${prNumber}`,
781
+ isDM: false,
782
+ metadata: {
783
+ owner,
784
+ repo,
785
+ prNumber,
786
+ prTitle: pr.title,
787
+ prState: pr.state,
788
+ reviewCommentId
789
+ }
790
+ };
791
+ }
792
+ /**
793
+ * Encode platform data into a thread ID string.
794
+ *
795
+ * Thread ID formats:
796
+ * - PR-level: `github:{owner}/{repo}:{prNumber}`
797
+ * - Review comment: `github:{owner}/{repo}:{prNumber}:rc:{reviewCommentId}`
798
+ */
799
+ encodeThreadId(platformData) {
800
+ const { owner, repo, prNumber, reviewCommentId } = platformData;
801
+ if (reviewCommentId) {
802
+ return `github:${owner}/${repo}:${prNumber}:rc:${reviewCommentId}`;
803
+ }
804
+ return `github:${owner}/${repo}:${prNumber}`;
805
+ }
806
+ /**
807
+ * Decode thread ID string back to platform data.
808
+ */
809
+ decodeThreadId(threadId) {
810
+ if (!threadId.startsWith("github:")) {
811
+ throw new ValidationError(
812
+ "github",
813
+ `Invalid GitHub thread ID: ${threadId}`
814
+ );
815
+ }
816
+ const withoutPrefix = threadId.slice(7);
817
+ const rcMatch = withoutPrefix.match(/^([^/]+)\/([^:]+):(\d+):rc:(\d+)$/);
818
+ if (rcMatch) {
819
+ return {
820
+ owner: rcMatch[1],
821
+ repo: rcMatch[2],
822
+ prNumber: parseInt(rcMatch[3], 10),
823
+ reviewCommentId: parseInt(rcMatch[4], 10)
824
+ };
825
+ }
826
+ const prMatch = withoutPrefix.match(/^([^/]+)\/([^:]+):(\d+)$/);
827
+ if (prMatch) {
828
+ return {
829
+ owner: prMatch[1],
830
+ repo: prMatch[2],
831
+ prNumber: parseInt(prMatch[3], 10)
832
+ };
833
+ }
834
+ throw new ValidationError(
835
+ "github",
836
+ `Invalid GitHub thread ID format: ${threadId}`
837
+ );
838
+ }
839
+ /**
840
+ * Parse a raw message into normalized format.
841
+ */
842
+ parseMessage(raw) {
843
+ if (raw.type === "issue_comment") {
844
+ const threadId = this.encodeThreadId({
845
+ owner: raw.repository.owner.login,
846
+ repo: raw.repository.name,
847
+ prNumber: raw.prNumber
848
+ });
849
+ return this.parseIssueComment(
850
+ raw.comment,
851
+ { owner: raw.repository.owner, name: raw.repository.name },
852
+ raw.prNumber,
853
+ threadId
854
+ );
855
+ } else {
856
+ const rootCommentId = raw.comment.in_reply_to_id ?? raw.comment.id;
857
+ const threadId = this.encodeThreadId({
858
+ owner: raw.repository.owner.login,
859
+ repo: raw.repository.name,
860
+ prNumber: raw.prNumber,
861
+ reviewCommentId: rootCommentId
862
+ });
863
+ return this.parseReviewComment(
864
+ raw.comment,
865
+ { owner: raw.repository.owner, name: raw.repository.name },
866
+ raw.prNumber,
867
+ threadId
868
+ );
869
+ }
870
+ }
871
+ /**
872
+ * Render formatted content to GitHub markdown.
873
+ */
874
+ renderFormatted(content) {
875
+ return this.formatConverter.fromAst(content);
876
+ }
877
+ };
878
+ function createGitHubAdapter(config) {
879
+ return new GitHubAdapter(config);
880
+ }
881
+ export {
882
+ GitHubAdapter,
883
+ createGitHubAdapter
884
+ };
885
+ //# sourceMappingURL=index.js.map