@zhin.js/adapter-github 0.1.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/lib/index.js ADDED
@@ -0,0 +1,705 @@
1
+ /**
2
+ * @zhin.js/adapter-github
3
+ *
4
+ * 把 GitHub 当聊天通道 — Issue/PR 评论区 = 群聊
5
+ * 通过 GitHub App 认证 (JWT → Installation Token),纯 REST API 对接,零 CLI 依赖
6
+ *
7
+ * 查询 · 管理 · 通知 三合一
8
+ */
9
+ import { Adapter, Message, usePlugin, ZhinTool, } from 'zhin.js';
10
+ import { parseChannelId, buildChannelId } from './types.js';
11
+ import { GitHubAPI } from './api.js';
12
+ import crypto from 'node:crypto';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ // ============================================================================
16
+ // Plugin 初始化
17
+ // ============================================================================
18
+ const plugin = usePlugin();
19
+ const { provide, defineModel, useContext, root, logger } = plugin;
20
+ defineModel('github_subscriptions', {
21
+ id: { type: 'integer', primary: true },
22
+ repo: { type: 'text', nullable: false },
23
+ events: { type: 'json', default: [] },
24
+ target_id: { type: 'text', nullable: false },
25
+ target_type: { type: 'text', nullable: false },
26
+ adapter: { type: 'text', nullable: false },
27
+ bot: { type: 'text', nullable: false },
28
+ });
29
+ defineModel('github_events', {
30
+ id: { type: 'integer', primary: true },
31
+ repo: { type: 'text', nullable: false },
32
+ event_type: { type: 'text', nullable: false },
33
+ payload: { type: 'json', default: {} },
34
+ });
35
+ const VALID_EVENTS = ['push', 'issue', 'star', 'fork', 'unstar', 'pull_request'];
36
+ // ============================================================================
37
+ // GitHubBot
38
+ // ============================================================================
39
+ function resolvePrivateKey(raw) {
40
+ if (raw.includes('-----BEGIN'))
41
+ return raw;
42
+ const resolved = path.resolve(raw);
43
+ if (fs.existsSync(resolved))
44
+ return fs.readFileSync(resolved, 'utf-8');
45
+ throw new Error(`private_key 既不是 PEM 内容也不是有效的文件路径: ${raw}`);
46
+ }
47
+ export class GitHubBot {
48
+ adapter;
49
+ $config;
50
+ $connected = false;
51
+ api;
52
+ get $id() { return this.$config.name; }
53
+ constructor(adapter, $config) {
54
+ this.adapter = adapter;
55
+ this.$config = $config;
56
+ const privateKey = resolvePrivateKey($config.private_key);
57
+ this.api = new GitHubAPI($config.app_id, privateKey, $config.installation_id);
58
+ }
59
+ async $connect() {
60
+ const result = await this.api.verifyAuth();
61
+ if (!result.ok)
62
+ throw new Error(`GitHub 认证失败: ${result.message}`);
63
+ this.$connected = true;
64
+ logger.info(`GitHub bot ${this.$id} 已连接 — ${result.message}`);
65
+ }
66
+ async $disconnect() {
67
+ this.$connected = false;
68
+ logger.info(`GitHub bot ${this.$id} 已断开`);
69
+ }
70
+ // ── Webhook → Message ──────────────────────────────────────────────
71
+ $formatMessage(payload) {
72
+ const repo = payload.repository.full_name;
73
+ const number = payload.issue.number;
74
+ const isPR = 'pull_request' in payload.issue;
75
+ const channelId = buildChannelId(repo, isPR ? 'pr' : 'issue', number);
76
+ const api = this.api;
77
+ const result = Message.from(payload, {
78
+ $id: payload.comment.id.toString(),
79
+ $adapter: 'github',
80
+ $bot: this.$config.name,
81
+ $sender: { id: payload.sender.login, name: payload.sender.login },
82
+ $channel: { id: channelId, type: 'group' },
83
+ $content: parseMarkdown(payload.comment.body),
84
+ $raw: payload.comment.body,
85
+ $timestamp: new Date(payload.comment.created_at).getTime(),
86
+ $recall: async () => { await api.deleteIssueComment(repo, payload.comment.id); },
87
+ $reply: async (content, quote) => {
88
+ const text = toMarkdown(content);
89
+ const finalBody = quote ? `> ${payload.comment.body.split('\n')[0]}\n\n${text}` : text;
90
+ const r = await api.createIssueComment(repo, number, finalBody);
91
+ return r.ok ? r.data.id.toString() : '';
92
+ },
93
+ });
94
+ return result;
95
+ }
96
+ formatPRReviewComment(payload) {
97
+ const repo = payload.repository.full_name;
98
+ const number = payload.pull_request.number;
99
+ const channelId = buildChannelId(repo, 'pr', number);
100
+ const api = this.api;
101
+ const body = payload.comment.path
102
+ ? `**${payload.comment.path}**\n${payload.comment.diff_hunk ? '```diff\n' + payload.comment.diff_hunk + '\n```\n' : ''}${payload.comment.body}`
103
+ : payload.comment.body;
104
+ return Message.from(payload, {
105
+ $id: payload.comment.id.toString(),
106
+ $adapter: 'github',
107
+ $bot: this.$config.name,
108
+ $sender: { id: payload.sender.login, name: payload.sender.login },
109
+ $channel: { id: channelId, type: 'group' },
110
+ $content: parseMarkdown(body),
111
+ $raw: body,
112
+ $timestamp: new Date(payload.comment.created_at).getTime(),
113
+ $recall: async () => { await api.deletePRReviewComment(repo, payload.comment.id); },
114
+ $reply: async (content) => {
115
+ const r = await api.createPRComment(repo, number, toMarkdown(content));
116
+ return r.ok ? r.data.id.toString() : '';
117
+ },
118
+ });
119
+ }
120
+ formatPRReview(payload) {
121
+ if (!payload.review.body)
122
+ return null;
123
+ const repo = payload.repository.full_name;
124
+ const number = payload.pull_request.number;
125
+ const channelId = buildChannelId(repo, 'pr', number);
126
+ const api = this.api;
127
+ const stateLabel = {
128
+ approved: '✅ APPROVED', changes_requested: '🔄 CHANGES REQUESTED',
129
+ commented: '💬 COMMENTED', dismissed: '❌ DISMISSED',
130
+ };
131
+ const body = `**[${stateLabel[payload.review.state] || payload.review.state}]**\n${payload.review.body}`;
132
+ return Message.from(payload, {
133
+ $id: payload.review.id.toString(),
134
+ $adapter: 'github',
135
+ $bot: this.$config.name,
136
+ $sender: { id: payload.sender.login, name: payload.sender.login },
137
+ $channel: { id: channelId, type: 'group' },
138
+ $content: parseMarkdown(body),
139
+ $raw: body,
140
+ $timestamp: new Date(payload.review.submitted_at).getTime(),
141
+ $recall: async () => { },
142
+ $reply: async (content) => {
143
+ const r = await api.createPRComment(repo, number, toMarkdown(content));
144
+ return r.ok ? r.data.id.toString() : '';
145
+ },
146
+ });
147
+ }
148
+ // ── 发送 & 撤回 ───────────────────────────────────────────────────
149
+ async $sendMessage(options) {
150
+ const parsed = parseChannelId(options.id);
151
+ if (!parsed)
152
+ throw new Error(`无效的 GitHub channel ID: ${options.id}`);
153
+ const text = toMarkdown(options.content);
154
+ const r = parsed.type === 'issue'
155
+ ? await this.api.createIssueComment(parsed.repo, parsed.number, text)
156
+ : await this.api.createPRComment(parsed.repo, parsed.number, text);
157
+ if (!r.ok)
158
+ throw new Error(`发送失败: ${JSON.stringify(r.data)}`);
159
+ logger.debug(`${this.$id} send → ${options.id}: ${text.slice(0, 80)}...`);
160
+ return r.data.id.toString();
161
+ }
162
+ async $recallMessage(id) {
163
+ logger.warn('$recallMessage 需要 repo 信息,请使用 message.$recall()');
164
+ }
165
+ }
166
+ // ============================================================================
167
+ // GitHubAdapter
168
+ // ============================================================================
169
+ class GitHubAdapter extends Adapter {
170
+ constructor(plugin) {
171
+ super(plugin, 'github', []);
172
+ }
173
+ createBot(config) {
174
+ return new GitHubBot(this, config);
175
+ }
176
+ async start() {
177
+ this.registerGitHubTools();
178
+ this.declareSkill({
179
+ description: 'GitHub 全功能适配器:Issue/PR 评论即聊天通道,仓库管理(PR合并/创建/Review、Issue管理)、信息查询(Star/CI/Release/Branch)、Webhook 事件通知订阅。通过 GitHub App 认证,纯 REST API。',
180
+ keywords: [
181
+ 'github', 'pr', 'pull request', 'issue', 'merge', 'review',
182
+ 'star', 'fork', 'branch', 'release', 'CI', 'workflow', 'repo',
183
+ '合并', '仓库', '拉取请求', '代码审查',
184
+ ],
185
+ tags: ['github', 'development', 'git', 'ci-cd'],
186
+ conventions: 'channel ID 格式 owner/repo/issues/N 或 owner/repo/pull/N。repo 参数不填则需要手动指定。',
187
+ });
188
+ await super.start();
189
+ }
190
+ /** 获取第一个可用 bot 的 API (工具用) */
191
+ getAPI() {
192
+ const bot = this.bots.values().next().value;
193
+ return bot?.api || null;
194
+ }
195
+ // ── Webhook 路由 (由 useContext('router') 注入) ────────────────────
196
+ setupWebhook(router) {
197
+ router.post('/api/github/webhook', async (ctx) => {
198
+ try {
199
+ const eventName = ctx.request.headers['x-github-event'];
200
+ const signature = ctx.request.headers['x-hub-signature-256'];
201
+ const payload = ctx.body;
202
+ logger.info(`GitHub Webhook: ${eventName} - ${payload?.repository?.full_name}`);
203
+ if (signature) {
204
+ let verified = false;
205
+ for (const bot of this.bots.values()) {
206
+ const secret = bot.$config.webhook_secret;
207
+ if (!secret)
208
+ continue;
209
+ const expected = `sha256=${crypto.createHmac('sha256', secret).update(JSON.stringify(ctx.body)).digest('hex')}`;
210
+ if (signature === expected) {
211
+ verified = true;
212
+ break;
213
+ }
214
+ }
215
+ if (!verified) {
216
+ const hasSecret = Array.from(this.bots.values()).some(b => b.$config.webhook_secret);
217
+ if (hasSecret) {
218
+ logger.warn('GitHub Webhook 签名验证失败');
219
+ ctx.status = 401;
220
+ ctx.body = { error: 'Invalid signature' };
221
+ return;
222
+ }
223
+ }
224
+ }
225
+ if (!payload?.repository) {
226
+ ctx.status = 400;
227
+ ctx.body = { error: 'Invalid payload' };
228
+ return;
229
+ }
230
+ const db = root.inject('database');
231
+ const eventsModel = db?.models?.get('github_events');
232
+ if (eventsModel) {
233
+ await eventsModel.insert({ id: Date.now(), repo: payload.repository.full_name, event_type: eventName, payload });
234
+ }
235
+ const bot = this.bots.values().next().value;
236
+ if (bot) {
237
+ let message = null;
238
+ if (eventName === 'issue_comment' && payload.action === 'created') {
239
+ message = bot.$formatMessage(payload);
240
+ }
241
+ else if (eventName === 'pull_request_review_comment' && payload.action === 'created') {
242
+ message = bot.formatPRReviewComment(payload);
243
+ }
244
+ else if (eventName === 'pull_request_review' && payload.action === 'submitted') {
245
+ message = bot.formatPRReview(payload);
246
+ }
247
+ if (message) {
248
+ const botUser = bot.api.authenticatedUser;
249
+ if (botUser && message.$sender.id === botUser) {
250
+ logger.debug(`忽略 bot 自身评论: ${message.$sender.id}`);
251
+ }
252
+ else {
253
+ this.emit('message.receive', message);
254
+ }
255
+ }
256
+ }
257
+ await this.dispatchNotification(eventName, payload);
258
+ ctx.status = 200;
259
+ ctx.body = { message: 'OK' };
260
+ }
261
+ catch (error) {
262
+ logger.error('Webhook 处理失败:', error);
263
+ ctx.status = 500;
264
+ ctx.body = { error: 'Internal server error' };
265
+ }
266
+ });
267
+ logger.info('GitHub Webhook: POST /api/github/webhook');
268
+ }
269
+ // ── GitHub 管理工具 ────────────────────────────────────────────────
270
+ registerGitHubTools() {
271
+ // --- PR ---
272
+ this.addTool(new ZhinTool('github.pr')
273
+ .desc('GitHub PR 操作:list/view/diff/merge/create/review/close')
274
+ .keyword('pr', 'pull request', '合并', 'merge', 'review', '审查', '拉取请求')
275
+ .tag('github', 'pr')
276
+ .param('action', { type: 'string', description: 'list|view|diff|merge|create|review|close', enum: ['list', 'view', 'diff', 'merge', 'create', 'review', 'close'] }, true)
277
+ .param('repo', { type: 'string', description: 'owner/repo (必填)' }, true)
278
+ .param('number', { type: 'number', description: 'PR 编号' })
279
+ .param('title', { type: 'string', description: 'PR 标题 (create)' })
280
+ .param('body', { type: 'string', description: 'PR 描述 / Review 评语' })
281
+ .param('head', { type: 'string', description: '源分支 (create)' })
282
+ .param('base', { type: 'string', description: '目标分支 (create,默认 main)' })
283
+ .param('state', { type: 'string', description: 'open/closed/all (list)' })
284
+ .param('approve', { type: 'boolean', description: 'review 时 approve' })
285
+ .param('method', { type: 'string', description: 'squash/merge/rebase (merge)' })
286
+ .execute(async (args) => {
287
+ const api = this.getAPI();
288
+ if (!api)
289
+ return '❌ 没有可用的 GitHub bot';
290
+ const action = args.action;
291
+ const repo = args.repo;
292
+ const num = args.number;
293
+ switch (action) {
294
+ case 'list': {
295
+ const r = await api.listPRs(repo, args.state || 'open');
296
+ if (!r.ok)
297
+ return `❌ ${JSON.stringify(r.data)}`;
298
+ if (!r.data.length)
299
+ return `📭 没有 ${args.state || 'open'} 状态的 PR`;
300
+ return r.data.map((p) => `#${p.number} ${p.draft ? '[Draft] ' : ''}${p.title}\n 👤 ${p.user.login} | 🌿 ${p.head.ref} → ${p.base.ref} | ${p.state}`).join('\n\n');
301
+ }
302
+ case 'view': {
303
+ if (!num)
304
+ return '❌ 请提供 PR 编号';
305
+ const r = await api.getPR(repo, num);
306
+ if (!r.ok)
307
+ return `❌ ${JSON.stringify(r.data)}`;
308
+ const p = r.data;
309
+ return [
310
+ `#${p.number} ${p.title}`,
311
+ `👤 ${p.user.login} | ${p.state} | 🌿 ${p.head.ref} → ${p.base.ref}`,
312
+ `📅 ${p.created_at?.split('T')[0]} | +${p.additions} -${p.deletions} (${p.changed_files} files)`,
313
+ p.body ? `\n${p.body.slice(0, 500)}${p.body.length > 500 ? '...' : ''}` : '',
314
+ `\n🔗 ${p.html_url}`,
315
+ ].filter(Boolean).join('\n');
316
+ }
317
+ case 'diff': {
318
+ if (!num)
319
+ return '❌ 请提供 PR 编号';
320
+ const r = await api.getPRDiff(repo, num);
321
+ if (!r.ok)
322
+ return `❌ 获取 diff 失败`;
323
+ const lines = r.data.split('\n');
324
+ return lines.length > 100 ? lines.slice(0, 100).join('\n') + `\n\n... (共 ${lines.length} 行)` : r.data;
325
+ }
326
+ case 'merge': {
327
+ if (!num)
328
+ return '❌ 请提供 PR 编号';
329
+ const r = await api.mergePR(repo, num, args.method || 'squash');
330
+ return r.ok ? `✅ PR #${num} 已合并` : `❌ ${r.data?.message || JSON.stringify(r.data)}`;
331
+ }
332
+ case 'create': {
333
+ if (!args.title)
334
+ return '❌ 请提供 PR 标题';
335
+ if (!args.head)
336
+ return '❌ 请提供源分支 (head)';
337
+ const r = await api.createPR(repo, args.title, args.body || '', args.head, args.base || 'main');
338
+ return r.ok ? `✅ PR 已创建: ${r.data.html_url}` : `❌ ${r.data?.message || JSON.stringify(r.data)}`;
339
+ }
340
+ case 'review': {
341
+ if (!num)
342
+ return '❌ 请提供 PR 编号';
343
+ const event = args.approve ? 'APPROVE' : 'COMMENT';
344
+ const r = await api.createPRReview(repo, num, event, args.body || undefined);
345
+ return r.ok ? `✅ PR #${num} ${args.approve ? '已批准' : '已评论'}` : `❌ ${r.data?.message}`;
346
+ }
347
+ case 'close': {
348
+ if (!num)
349
+ return '❌ 请提供 PR 编号';
350
+ const r = await api.closePR(repo, num);
351
+ return r.ok ? `✅ PR #${num} 已关闭` : `❌ ${r.data?.message}`;
352
+ }
353
+ default: return `❌ 未知操作: ${action}`;
354
+ }
355
+ }));
356
+ // --- Issue ---
357
+ this.addTool(new ZhinTool('github.issue')
358
+ .desc('GitHub Issue 操作:list/view/create/close/comment')
359
+ .keyword('issue', '问题', 'bug', '创建issue', '关闭issue')
360
+ .tag('github', 'issue')
361
+ .param('action', { type: 'string', description: 'list|view|create|close|comment', enum: ['list', 'view', 'create', 'close', 'comment'] }, true)
362
+ .param('repo', { type: 'string', description: 'owner/repo (必填)' }, true)
363
+ .param('number', { type: 'number', description: 'Issue 编号' })
364
+ .param('title', { type: 'string', description: 'Issue 标题 (create)' })
365
+ .param('body', { type: 'string', description: 'Issue 内容 / 评论内容' })
366
+ .param('labels', { type: 'string', description: '标签,逗号分隔 (create)' })
367
+ .param('state', { type: 'string', description: 'open/closed/all (list)' })
368
+ .execute(async (args) => {
369
+ const api = this.getAPI();
370
+ if (!api)
371
+ return '❌ 没有可用的 GitHub bot';
372
+ const action = args.action;
373
+ const repo = args.repo;
374
+ const num = args.number;
375
+ switch (action) {
376
+ case 'list': {
377
+ const r = await api.listIssues(repo, args.state || 'open');
378
+ if (!r.ok)
379
+ return `❌ ${JSON.stringify(r.data)}`;
380
+ // 过滤掉 PR (GitHub API 的 issues 接口也返回 PR)
381
+ const issues = r.data.filter((i) => !i.pull_request);
382
+ if (!issues.length)
383
+ return `📭 没有 ${args.state || 'open'} 状态的 Issue`;
384
+ return issues.map((i) => {
385
+ const labels = i.labels?.map((l) => l.name).join(', ') || '';
386
+ return `#${i.number} ${i.title}\n 👤 ${i.user.login}${labels ? ` | 🏷️ ${labels}` : ''} | ${i.state}`;
387
+ }).join('\n\n');
388
+ }
389
+ case 'view': {
390
+ if (!num)
391
+ return '❌ 请提供 Issue 编号';
392
+ const r = await api.getIssue(repo, num);
393
+ if (!r.ok)
394
+ return `❌ ${JSON.stringify(r.data)}`;
395
+ const i = r.data;
396
+ return [
397
+ `#${i.number} ${i.title}`,
398
+ `👤 ${i.user.login} | ${i.state} | 📅 ${i.created_at?.split('T')[0]}`,
399
+ i.labels?.length ? `🏷️ ${i.labels.map((l) => l.name).join(', ')}` : null,
400
+ i.body ? `\n${i.body.slice(0, 500)}${i.body.length > 500 ? '...' : ''}` : '',
401
+ `\n🔗 ${i.html_url}`,
402
+ ].filter(Boolean).join('\n');
403
+ }
404
+ case 'create': {
405
+ if (!args.title)
406
+ return '❌ 请提供 Issue 标题';
407
+ const labels = args.labels ? args.labels.split(',').map(s => s.trim()) : undefined;
408
+ const r = await api.createIssue(repo, args.title, args.body || undefined, labels);
409
+ return r.ok ? `✅ Issue 已创建: ${r.data.html_url}` : `❌ ${r.data?.message}`;
410
+ }
411
+ case 'close': {
412
+ if (!num)
413
+ return '❌ 请提供 Issue 编号';
414
+ const r = await api.closeIssue(repo, num);
415
+ return r.ok ? `✅ Issue #${num} 已关闭` : `❌ ${r.data?.message}`;
416
+ }
417
+ case 'comment': {
418
+ if (!num)
419
+ return '❌ 请提供 Issue 编号';
420
+ if (!args.body)
421
+ return '❌ 请提供评论内容';
422
+ const r = await api.createIssueComment(repo, num, args.body);
423
+ return r.ok ? `✅ 已评论 Issue #${num}` : `❌ ${r.data?.message || JSON.stringify(r.data)}`;
424
+ }
425
+ default: return `❌ 未知操作: ${action}`;
426
+ }
427
+ }));
428
+ // --- Repo ---
429
+ this.addTool(new ZhinTool('github.repo')
430
+ .desc('GitHub 仓库查询:info/branches/releases/runs(CI)/stars')
431
+ .keyword('仓库', 'repo', 'star', '分支', 'branch', 'release', '发布', 'CI', 'workflow')
432
+ .tag('github', 'repo')
433
+ .param('action', { type: 'string', description: 'info|branches|releases|runs|stars', enum: ['info', 'branches', 'releases', 'runs', 'stars'] }, true)
434
+ .param('repo', { type: 'string', description: 'owner/repo (必填)' }, true)
435
+ .param('limit', { type: 'number', description: '返回数量,默认 10' })
436
+ .execute(async (args) => {
437
+ const api = this.getAPI();
438
+ if (!api)
439
+ return '❌ 没有可用的 GitHub bot';
440
+ const action = args.action;
441
+ const repo = args.repo;
442
+ const limit = args.limit || 10;
443
+ switch (action) {
444
+ case 'info': {
445
+ const r = await api.getRepo(repo);
446
+ if (!r.ok)
447
+ return `❌ ${JSON.stringify(r.data)}`;
448
+ const d = r.data;
449
+ return [
450
+ `📦 ${d.full_name}${d.private ? ' 🔒' : ''}`,
451
+ d.description ? `📝 ${d.description}` : null,
452
+ `⭐ ${d.stargazers_count} | 🍴 ${d.forks_count} | 👀 ${d.watchers_count}`,
453
+ `🌿 默认分支: ${d.default_branch}`,
454
+ d.license ? `📄 ${d.license.name}` : null,
455
+ d.homepage ? `🌐 ${d.homepage}` : null,
456
+ `📅 创建: ${d.created_at?.split('T')[0]} | 推送: ${d.pushed_at?.split('T')[0]}`,
457
+ ].filter(Boolean).join('\n');
458
+ }
459
+ case 'branches': {
460
+ const r = await api.listBranches(repo, limit);
461
+ if (!r.ok)
462
+ return `❌ ${JSON.stringify(r.data)}`;
463
+ return r.data.length
464
+ ? `🌿 分支 (${r.data.length}):\n${r.data.map((b) => ` • ${b.name}${b.protected ? ' 🔒' : ''}`).join('\n')}`
465
+ : '没有找到分支';
466
+ }
467
+ case 'releases': {
468
+ const r = await api.listReleases(repo, limit);
469
+ if (!r.ok)
470
+ return `❌ ${JSON.stringify(r.data)}`;
471
+ if (!r.data.length)
472
+ return '📭 暂无发布';
473
+ return r.data.map((rel) => `${rel.prerelease ? '🧪' : '📦'} ${rel.tag_name} — ${rel.name || '(no title)'}\n 📅 ${rel.published_at?.split('T')[0]} | 👤 ${rel.author?.login}`).join('\n\n');
474
+ }
475
+ case 'runs': {
476
+ const r = await api.listWorkflowRuns(repo, limit);
477
+ if (!r.ok)
478
+ return `❌ ${JSON.stringify(r.data)}`;
479
+ const runs = r.data.workflow_runs || [];
480
+ if (!runs.length)
481
+ return '📭 暂无 CI 记录';
482
+ return runs.map((run) => {
483
+ const icon = run.conclusion === 'success' ? '✅' : run.conclusion === 'failure' ? '❌' : run.status === 'in_progress' ? '🔄' : '⏳';
484
+ return `${icon} #${run.id} ${run.display_title}\n 🌿 ${run.head_branch} | ${run.status}${run.conclusion ? '/' + run.conclusion : ''}`;
485
+ }).join('\n\n');
486
+ }
487
+ case 'stars': {
488
+ const r = await api.getRepo(repo);
489
+ if (!r.ok)
490
+ return `❌ ${JSON.stringify(r.data)}`;
491
+ return `⭐ ${r.data.stargazers_count} stars | 🍴 ${r.data.forks_count} forks`;
492
+ }
493
+ default: return `❌ 未知查询: ${action}`;
494
+ }
495
+ }));
496
+ // --- Subscribe ---
497
+ this.addTool(new ZhinTool('github.subscribe')
498
+ .desc('订阅 GitHub 仓库 Webhook 事件通知(跨平台推送到当前聊天)')
499
+ .tag('github', 'subscription')
500
+ .param('repo', { type: 'string', description: 'owner/repo' }, true)
501
+ .param('events', { type: 'array', description: '事件: push, issue, star, fork, unstar, pr(留空=全部)' })
502
+ .execute(async ({ repo, events: evts = [] }, ctx) => {
503
+ if (!ctx?.message)
504
+ return '❌ 无法获取消息上下文';
505
+ const msg = ctx.message;
506
+ const repoStr = repo;
507
+ if (!repoStr.includes('/'))
508
+ return '❌ 格式应为 owner/repo';
509
+ const parsed = [];
510
+ for (const e of evts) {
511
+ const n = e.toLowerCase();
512
+ if (n === 'pr')
513
+ parsed.push('pull_request');
514
+ else if (VALID_EVENTS.includes(n))
515
+ parsed.push(n);
516
+ else
517
+ return `❌ 不支持: ${e}`;
518
+ }
519
+ const subEvents = parsed.length > 0 ? parsed : VALID_EVENTS;
520
+ const db = root.inject('database');
521
+ const model = db?.models?.get('github_subscriptions');
522
+ if (!model)
523
+ return '❌ 数据库未就绪';
524
+ const where = { repo: repoStr, target_id: msg.$channel.id, target_type: msg.$channel.type, adapter: msg.$adapter, bot: msg.$bot };
525
+ const [existing] = await model.select().where(where);
526
+ if (existing) {
527
+ await model.update({ events: subEvents }).where({ id: existing.id });
528
+ return `✅ 已更新订阅 ${repoStr}\n📢 ${subEvents.join(', ')}`;
529
+ }
530
+ const { repo: _r, ...rest } = where;
531
+ await model.insert({ id: Date.now(), repo: repoStr, events: subEvents, ...rest });
532
+ return `✅ 已订阅 ${repoStr}\n📢 ${subEvents.join(', ')}\n💡 记得在 GitHub App 或仓库 Settings → Webhooks 中配置 Webhook`;
533
+ }));
534
+ // --- Unsubscribe ---
535
+ this.addTool(new ZhinTool('github.unsubscribe')
536
+ .desc('取消订阅 GitHub 仓库事件通知')
537
+ .tag('github', 'subscription')
538
+ .param('repo', { type: 'string', description: 'owner/repo' }, true)
539
+ .execute(async ({ repo }, ctx) => {
540
+ if (!ctx?.message)
541
+ return '❌ 无法获取消息上下文';
542
+ const msg = ctx.message;
543
+ const db = root.inject('database');
544
+ const model = db?.models?.get('github_subscriptions');
545
+ if (!model)
546
+ return '❌ 数据库未就绪';
547
+ const [sub] = await model.select().where({ repo: repo, target_id: msg.$channel.id, target_type: msg.$channel.type, adapter: msg.$adapter, bot: msg.$bot });
548
+ if (!sub)
549
+ return `❌ 未找到订阅: ${repo}`;
550
+ await model.delete({ id: sub.id });
551
+ return `✅ 已取消订阅 ${repo}`;
552
+ }));
553
+ // --- List subscriptions ---
554
+ this.addTool(new ZhinTool('github.subscriptions')
555
+ .desc('查看当前聊天的 GitHub 事件订阅列表')
556
+ .tag('github', 'subscription')
557
+ .execute(async (_args, ctx) => {
558
+ if (!ctx?.message)
559
+ return '❌ 无法获取消息上下文';
560
+ const msg = ctx.message;
561
+ const db = root.inject('database');
562
+ const model = db?.models?.get('github_subscriptions');
563
+ if (!model)
564
+ return '❌ 数据库未就绪';
565
+ const subs = await model.select().where({ target_id: msg.$channel.id, target_type: msg.$channel.type, adapter: msg.$adapter, bot: msg.$bot });
566
+ if (!subs?.length)
567
+ return '📭 当前没有订阅';
568
+ return `📋 订阅 (${subs.length}):\n\n` + subs.map((s, i) => `${i + 1}. ${s.repo}\n 📢 ${(s.events || []).join(', ')}`).join('\n\n');
569
+ }));
570
+ logger.debug('GitHub 工具已注册: pr, issue, repo, subscribe, unsubscribe, subscriptions');
571
+ }
572
+ // ── 通知推送 ───────────────────────────────────────────────────────
573
+ async dispatchNotification(eventName, payload) {
574
+ let eventType = null;
575
+ switch (eventName) {
576
+ case 'push':
577
+ eventType = 'push';
578
+ break;
579
+ case 'issues':
580
+ eventType = 'issue';
581
+ break;
582
+ case 'star':
583
+ eventType = payload.action === 'deleted' ? 'unstar' : 'star';
584
+ break;
585
+ case 'fork':
586
+ eventType = 'fork';
587
+ break;
588
+ case 'pull_request':
589
+ eventType = 'pull_request';
590
+ break;
591
+ }
592
+ if (!eventType)
593
+ return;
594
+ const repo = payload.repository.full_name;
595
+ const db = root.inject('database');
596
+ const model = db?.models?.get('github_subscriptions');
597
+ if (!model)
598
+ return;
599
+ const subs = await model.select().where({ repo });
600
+ if (!subs?.length)
601
+ return;
602
+ const text = formatNotification(eventName, payload);
603
+ for (const sub of subs) {
604
+ const s = sub;
605
+ if (!s.events.includes(eventType))
606
+ continue;
607
+ try {
608
+ const adapter = root.inject(s.adapter);
609
+ if (adapter?.sendMessage) {
610
+ await adapter.sendMessage({ context: s.adapter, bot: s.bot, id: s.target_id, type: s.target_type, content: text });
611
+ }
612
+ }
613
+ catch (e) {
614
+ logger.error(`通知推送失败 → ${s.adapter}:${s.target_id}`, e);
615
+ }
616
+ }
617
+ }
618
+ }
619
+ // ── 工具函数 ─────────────────────────────────────────────────────────
620
+ function parseMarkdown(md) {
621
+ const segments = [];
622
+ const mentionRe = /@(\w[-\w]*)/g;
623
+ let lastIdx = 0;
624
+ let match;
625
+ while ((match = mentionRe.exec(md)) !== null) {
626
+ if (match.index > lastIdx)
627
+ segments.push({ type: 'text', data: { text: md.slice(lastIdx, match.index) } });
628
+ segments.push({ type: 'at', data: { id: match[1], name: match[1], text: match[0] } });
629
+ lastIdx = match.index + match[0].length;
630
+ }
631
+ if (lastIdx < md.length)
632
+ segments.push({ type: 'text', data: { text: md.slice(lastIdx) } });
633
+ return segments.length ? segments : [{ type: 'text', data: { text: md } }];
634
+ }
635
+ function toMarkdown(content) {
636
+ if (!Array.isArray(content))
637
+ content = [content];
638
+ return content.map(seg => {
639
+ if (typeof seg === 'string')
640
+ return seg;
641
+ switch (seg.type) {
642
+ case 'text': return seg.data.text || '';
643
+ case 'at': return `@${seg.data.name || seg.data.id}`;
644
+ case 'image': return seg.data.url ? `![image](${seg.data.url})` : '[image]';
645
+ case 'link': return `[${seg.data.text || seg.data.url}](${seg.data.url})`;
646
+ default: return seg.data?.text || `[${seg.type}]`;
647
+ }
648
+ }).join('');
649
+ }
650
+ function formatNotification(event, p) {
651
+ const repo = p.repository.full_name;
652
+ const sender = p.sender.login;
653
+ switch (event) {
654
+ case 'push': {
655
+ const branch = p.ref?.replace('refs/heads/', '') || '?';
656
+ const commits = p.commits || [];
657
+ let msg = `📦 ${repo}\n🌿 ${sender} pushed to ${branch}\n\n`;
658
+ if (commits.length) {
659
+ msg += `📝 ${commits.length} commit(s):\n`;
660
+ msg += commits.slice(0, 3).map(c => ` • ${c.id.substring(0, 7)} ${c.message.split('\n')[0]}`).join('\n');
661
+ if (commits.length > 3)
662
+ msg += `\n ... +${commits.length - 3} more`;
663
+ }
664
+ return msg;
665
+ }
666
+ case 'issues': {
667
+ const i = p.issue;
668
+ const act = p.action === 'opened' ? 'opened' : p.action === 'closed' ? 'closed' : 'updated';
669
+ return `🐛 ${repo}\n👤 ${sender} ${act} issue #${i.number}\n📌 ${i.title}`;
670
+ }
671
+ case 'star': {
672
+ const starred = p.action !== 'deleted';
673
+ return `${starred ? '⭐' : '💔'} ${repo}\n👤 ${sender} ${starred ? 'starred' : 'unstarred'}`;
674
+ }
675
+ case 'fork':
676
+ return `🍴 ${repo}\n👤 ${sender} forked → ${p.forkee.full_name}`;
677
+ case 'pull_request': {
678
+ const pr = p.pull_request;
679
+ const act = p.action === 'opened' ? 'opened' : p.action === 'closed' ? 'closed' : 'updated';
680
+ return `🔀 ${repo}\n👤 ${sender} ${act} PR #${pr.number}\n📌 ${pr.title}`;
681
+ }
682
+ default:
683
+ return `📬 ${repo}\n${event} by ${sender}`;
684
+ }
685
+ }
686
+ // ============================================================================
687
+ // 注册适配器
688
+ // ============================================================================
689
+ provide({
690
+ name: 'github',
691
+ description: 'GitHub Adapter — Issues/PRs as chat channels, full repo management via GitHub App',
692
+ mounted: async (p) => {
693
+ const adapter = new GitHubAdapter(p);
694
+ await adapter.start();
695
+ return adapter;
696
+ },
697
+ dispose: async (adapter) => {
698
+ await adapter.stop();
699
+ },
700
+ });
701
+ useContext('router', 'github', (router, adapter) => {
702
+ adapter.setupWebhook(router);
703
+ });
704
+ logger.info('GitHub 适配器已加载 (GitHub App 认证)');
705
+ //# sourceMappingURL=index.js.map