@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/LICENSE +9 -0
- package/README.md +255 -0
- package/dist/index.d.ts +377 -0
- package/dist/index.js +885 -0
- package/dist/index.js.map +1 -0
- package/package.json +60 -0
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(``);
|
|
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 [``];
|
|
49
|
+
}
|
|
50
|
+
return [``];
|
|
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
|