@zhin.js/adapter-github 0.1.20 → 0.1.21
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/adapter.d.ts.map +1 -1
- package/lib/adapter.js +0 -12
- package/lib/adapter.js.map +1 -1
- package/package.json +8 -4
- package/skills/github/SKILL.md +43 -0
- package/src/adapter.ts +1025 -0
- package/src/api.ts +353 -0
- package/src/bot.ts +187 -0
- package/src/index.ts +90 -0
- package/src/types.ts +167 -0
package/src/api.ts
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub REST API 客户端
|
|
3
|
+
*
|
|
4
|
+
* 两种认证方式:
|
|
5
|
+
* 1. GitHub App (JWT): app_id + private_key → JWT → Installation Access Token
|
|
6
|
+
* 2. OAuth User: access_token → 以用户身份调用 API
|
|
7
|
+
*
|
|
8
|
+
* 零外部依赖,使用 Node.js 原生 crypto + fetch
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
|
|
13
|
+
const API_BASE = 'https://api.github.com';
|
|
14
|
+
const ACCEPT = 'application/vnd.github+json';
|
|
15
|
+
const API_VERSION = '2022-11-28';
|
|
16
|
+
|
|
17
|
+
// ── JWT ──────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function createAppJWT(appId: number, privateKey: string): string {
|
|
20
|
+
const now = Math.floor(Date.now() / 1000);
|
|
21
|
+
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
|
|
22
|
+
const payload = Buffer.from(JSON.stringify({
|
|
23
|
+
iss: appId,
|
|
24
|
+
iat: now - 60,
|
|
25
|
+
exp: now + 600,
|
|
26
|
+
})).toString('base64url');
|
|
27
|
+
|
|
28
|
+
const sign = crypto.createSign('RSA-SHA256');
|
|
29
|
+
sign.update(`${header}.${payload}`);
|
|
30
|
+
const signature = sign.sign(privateKey, 'base64url');
|
|
31
|
+
|
|
32
|
+
return `${header}.${payload}.${signature}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── API Client ───────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export class GitHubAPI {
|
|
38
|
+
private installationToken: string | null = null;
|
|
39
|
+
private tokenExpiresAt = 0;
|
|
40
|
+
private _user: string | null = null;
|
|
41
|
+
|
|
42
|
+
constructor(
|
|
43
|
+
private appId: number,
|
|
44
|
+
private privateKey: string,
|
|
45
|
+
private installationId?: number,
|
|
46
|
+
) {}
|
|
47
|
+
|
|
48
|
+
get authenticatedUser() { return this._user; }
|
|
49
|
+
|
|
50
|
+
private async getToken(): Promise<string> {
|
|
51
|
+
if (this.installationToken && Date.now() < this.tokenExpiresAt - 60_000) {
|
|
52
|
+
return this.installationToken;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const jwt = createAppJWT(this.appId, this.privateKey);
|
|
56
|
+
|
|
57
|
+
if (!this.installationId) {
|
|
58
|
+
const res = await fetch(`${API_BASE}/app/installations`, {
|
|
59
|
+
headers: { Authorization: `Bearer ${jwt}`, Accept: ACCEPT, 'X-GitHub-Api-Version': API_VERSION },
|
|
60
|
+
});
|
|
61
|
+
if (!res.ok) throw new Error(`获取 installations 失败: ${res.status} ${await res.text()}`);
|
|
62
|
+
const installations = await res.json() as any[];
|
|
63
|
+
if (!installations.length) throw new Error('此 GitHub App 没有任何 installation');
|
|
64
|
+
this.installationId = installations[0].id;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const res = await fetch(`${API_BASE}/app/installations/${this.installationId}/access_tokens`, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { Authorization: `Bearer ${jwt}`, Accept: ACCEPT, 'X-GitHub-Api-Version': API_VERSION },
|
|
70
|
+
});
|
|
71
|
+
if (!res.ok) throw new Error(`获取 installation token 失败: ${res.status} ${await res.text()}`);
|
|
72
|
+
const data = await res.json() as { token: string; expires_at: string };
|
|
73
|
+
this.installationToken = data.token;
|
|
74
|
+
this.tokenExpiresAt = new Date(data.expires_at).getTime();
|
|
75
|
+
return this.installationToken;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async request<T = any>(method: string, path: string, body?: any): Promise<{ ok: boolean; status: number; data: T }> {
|
|
79
|
+
const token = await this.getToken();
|
|
80
|
+
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
|
|
81
|
+
const res = await fetch(url, {
|
|
82
|
+
method,
|
|
83
|
+
headers: {
|
|
84
|
+
Authorization: `Bearer ${token}`,
|
|
85
|
+
Accept: ACCEPT,
|
|
86
|
+
'X-GitHub-Api-Version': API_VERSION,
|
|
87
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
88
|
+
},
|
|
89
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
90
|
+
});
|
|
91
|
+
const text = await res.text();
|
|
92
|
+
let data: any;
|
|
93
|
+
try { data = JSON.parse(text); } catch { data = text; }
|
|
94
|
+
return { ok: res.ok, status: res.status, data };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private async get<T = any>(path: string) { return this.request<T>('GET', path); }
|
|
98
|
+
private async post<T = any>(path: string, body?: any) { return this.request<T>('POST', path, body); }
|
|
99
|
+
private async patch<T = any>(path: string, body?: any) { return this.request<T>('PATCH', path, body); }
|
|
100
|
+
private async put<T = any>(path: string, body?: any) { return this.request<T>('PUT', path, body); }
|
|
101
|
+
private async del<T = any>(path: string, body?: any) { return this.request<T>('DELETE', path, body); }
|
|
102
|
+
|
|
103
|
+
// ── 连接验证 ──────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
async verifyAuth(): Promise<{ ok: boolean; user: string; message: string }> {
|
|
106
|
+
try {
|
|
107
|
+
await this.getToken();
|
|
108
|
+
const jwt = createAppJWT(this.appId, this.privateKey);
|
|
109
|
+
const res = await fetch(`${API_BASE}/app`, {
|
|
110
|
+
headers: { Authorization: `Bearer ${jwt}`, Accept: ACCEPT, 'X-GitHub-Api-Version': API_VERSION },
|
|
111
|
+
});
|
|
112
|
+
if (!res.ok) return { ok: false, user: '', message: `App 认证失败: ${res.status}` };
|
|
113
|
+
const app = await res.json() as { name: string; slug: string };
|
|
114
|
+
this._user = `${app.slug}[bot]`;
|
|
115
|
+
return { ok: true, user: this._user, message: `GitHub App: ${app.name}` };
|
|
116
|
+
} catch (e: any) {
|
|
117
|
+
return { ok: false, user: '', message: e.message };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Issue 评论 (聊天核心) ─────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
async createIssueComment(repo: string, issueNumber: number, body: string) {
|
|
124
|
+
return this.post<{ id: number; html_url: string }>(`/repos/${repo}/issues/${issueNumber}/comments`, { body });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async deleteIssueComment(repo: string, commentId: number) {
|
|
128
|
+
return this.del(`/repos/${repo}/issues/comments/${commentId}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async createPRComment(repo: string, prNumber: number, body: string) {
|
|
132
|
+
return this.createIssueComment(repo, prNumber, body);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async deletePRReviewComment(repo: string, commentId: number) {
|
|
136
|
+
return this.del(`/repos/${repo}/pulls/comments/${commentId}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Pull Requests ─────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
async listPRs(repo: string, state: string = 'open', limit: number = 15) {
|
|
142
|
+
return this.get<any[]>(`/repos/${repo}/pulls?state=${state}&per_page=${limit}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async getPR(repo: string, number: number) {
|
|
146
|
+
return this.get<any>(`/repos/${repo}/pulls/${number}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async getPRDiff(repo: string, number: number): Promise<{ ok: boolean; data: string }> {
|
|
150
|
+
const token = await this.getToken();
|
|
151
|
+
const res = await fetch(`${API_BASE}/repos/${repo}/pulls/${number}`, {
|
|
152
|
+
headers: {
|
|
153
|
+
Authorization: `Bearer ${token}`,
|
|
154
|
+
Accept: 'application/vnd.github.diff',
|
|
155
|
+
'X-GitHub-Api-Version': API_VERSION,
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
return { ok: res.ok, data: await res.text() };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async mergePR(repo: string, number: number, method: string = 'squash') {
|
|
162
|
+
return this.put<any>(`/repos/${repo}/pulls/${number}/merge`, { merge_method: method });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async createPR(repo: string, title: string, body: string, head: string, base: string = 'main') {
|
|
166
|
+
return this.post<any>(`/repos/${repo}/pulls`, { title, body, head, base });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async createPRReview(repo: string, number: number, event: 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT', body?: string) {
|
|
170
|
+
return this.post<any>(`/repos/${repo}/pulls/${number}/reviews`, { event, body: body || '' });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async closePR(repo: string, number: number) {
|
|
174
|
+
return this.patch<any>(`/repos/${repo}/pulls/${number}`, { state: 'closed' });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Issues ────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
async listIssues(repo: string, state: string = 'open', limit: number = 15) {
|
|
180
|
+
return this.get<any[]>(`/repos/${repo}/issues?state=${state}&per_page=${limit}&direction=desc`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async getIssue(repo: string, number: number) {
|
|
184
|
+
return this.get<any>(`/repos/${repo}/issues/${number}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async createIssue(repo: string, title: string, body?: string, labels?: string[]) {
|
|
188
|
+
return this.post<any>(`/repos/${repo}/issues`, { title, body, labels });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async closeIssue(repo: string, number: number) {
|
|
192
|
+
return this.patch<any>(`/repos/${repo}/issues/${number}`, { state: 'closed', state_reason: 'completed' });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Repository ────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
async getRepo(repo: string) {
|
|
198
|
+
return this.get<any>(`/repos/${repo}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async listBranches(repo: string, limit: number = 30) {
|
|
202
|
+
return this.get<any[]>(`/repos/${repo}/branches?per_page=${limit}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async listReleases(repo: string, limit: number = 10) {
|
|
206
|
+
return this.get<any[]>(`/repos/${repo}/releases?per_page=${limit}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async listWorkflowRuns(repo: string, limit: number = 10) {
|
|
210
|
+
return this.get<{ total_count: number; workflow_runs: any[] }>(`/repos/${repo}/actions/runs?per_page=${limit}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Search ───────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
async searchIssues(query: string, limit: number = 15) {
|
|
216
|
+
return this.get<{ total_count: number; items: any[] }>(`/search/issues?q=${encodeURIComponent(query)}&per_page=${limit}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async searchRepos(query: string, limit: number = 15) {
|
|
220
|
+
return this.get<{ total_count: number; items: any[] }>(`/search/repositories?q=${encodeURIComponent(query)}&per_page=${limit}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async searchCode(query: string, limit: number = 15) {
|
|
224
|
+
return this.get<{ total_count: number; items: any[] }>(`/search/code?q=${encodeURIComponent(query)}&per_page=${limit}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Labels ───────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
async listLabels(repo: string) {
|
|
230
|
+
return this.get<any[]>(`/repos/${repo}/labels?per_page=100`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async addLabels(repo: string, issueNumber: number, labels: string[]) {
|
|
234
|
+
return this.post<any[]>(`/repos/${repo}/issues/${issueNumber}/labels`, { labels });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async removeLabel(repo: string, issueNumber: number, label: string) {
|
|
238
|
+
return this.del(`/repos/${repo}/issues/${issueNumber}/labels/${encodeURIComponent(label)}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Assignees ────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
async addAssignees(repo: string, issueNumber: number, assignees: string[]) {
|
|
244
|
+
return this.post<any>(`/repos/${repo}/issues/${issueNumber}/assignees`, { assignees });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async removeAssignees(repo: string, issueNumber: number, assignees: string[]) {
|
|
248
|
+
return this.del<any>(`/repos/${repo}/issues/${issueNumber}/assignees`, { assignees });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── File Content ─────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
async getFileContent(repo: string, filePath: string, ref?: string) {
|
|
254
|
+
const qs = ref ? `?ref=${encodeURIComponent(ref)}` : '';
|
|
255
|
+
return this.get<any>(`/repos/${repo}/contents/${filePath}${qs}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Commits ──────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
async listCommits(repo: string, sha?: string, filePath?: string, limit: number = 15) {
|
|
261
|
+
const params = new URLSearchParams({ per_page: String(limit) });
|
|
262
|
+
if (sha) params.set('sha', sha);
|
|
263
|
+
if (filePath) params.set('path', filePath);
|
|
264
|
+
return this.get<any[]>(`/repos/${repo}/commits?${params}`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async compareCommits(repo: string, base: string, head: string) {
|
|
268
|
+
return this.get<any>(`/repos/${repo}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── Update Issue / PR ────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
async updateIssue(repo: string, number: number, data: { title?: string; body?: string; state?: string; labels?: string[]; assignees?: string[] }) {
|
|
274
|
+
return this.patch<any>(`/repos/${repo}/issues/${number}`, data);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async updatePR(repo: string, number: number, data: { title?: string; body?: string; state?: string; base?: string }) {
|
|
278
|
+
return this.patch<any>(`/repos/${repo}/pulls/${number}`, data);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── OAuth Token Exchange ─────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
export async function exchangeOAuthCode(
|
|
285
|
+
clientId: string,
|
|
286
|
+
clientSecret: string,
|
|
287
|
+
code: string,
|
|
288
|
+
): Promise<{ access_token: string; scope: string; token_type: string }> {
|
|
289
|
+
const res = await fetch('https://github.com/login/oauth/access_token', {
|
|
290
|
+
method: 'POST',
|
|
291
|
+
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
292
|
+
body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, code }),
|
|
293
|
+
});
|
|
294
|
+
if (!res.ok) throw new Error(`OAuth token exchange failed: ${res.status}`);
|
|
295
|
+
const data = await res.json() as any;
|
|
296
|
+
if (data.error) throw new Error(`OAuth error: ${data.error_description || data.error}`);
|
|
297
|
+
return data;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── OAuth User API Client ────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
type ApiResult<T = any> = { ok: boolean; status: number; data: T };
|
|
303
|
+
|
|
304
|
+
export class GitHubOAuthClient {
|
|
305
|
+
constructor(private accessToken: string) {}
|
|
306
|
+
|
|
307
|
+
async request<T = any>(method: string, path: string, body?: any): Promise<ApiResult<T>> {
|
|
308
|
+
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
|
|
309
|
+
const res = await fetch(url, {
|
|
310
|
+
method,
|
|
311
|
+
headers: {
|
|
312
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
313
|
+
Accept: ACCEPT,
|
|
314
|
+
'X-GitHub-Api-Version': API_VERSION,
|
|
315
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
316
|
+
},
|
|
317
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
318
|
+
});
|
|
319
|
+
const text = await res.text();
|
|
320
|
+
let data: any;
|
|
321
|
+
try { data = JSON.parse(text); } catch { data = text; }
|
|
322
|
+
return { ok: res.ok, status: res.status, data };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async getUser(): Promise<ApiResult<{ login: string; id: number; name: string | null; avatar_url: string }>> {
|
|
326
|
+
return this.request('GET', '/user');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async starRepo(repo: string): Promise<ApiResult> {
|
|
330
|
+
return this.request('PUT', `/user/starred/${repo}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async unstarRepo(repo: string): Promise<ApiResult> {
|
|
334
|
+
return this.request('DELETE', `/user/starred/${repo}`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async isStarred(repo: string): Promise<boolean> {
|
|
338
|
+
const res = await this.request('GET', `/user/starred/${repo}`);
|
|
339
|
+
return res.status === 204;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async forkRepo(repo: string): Promise<ApiResult> {
|
|
343
|
+
return this.request('POST', `/repos/${repo}/forks`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async createIssue(repo: string, title: string, body?: string, labels?: string[]): Promise<ApiResult> {
|
|
347
|
+
return this.request('POST', `/repos/${repo}/issues`, { title, body, labels });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async createPR(repo: string, title: string, body: string, head: string, base: string = 'main'): Promise<ApiResult> {
|
|
351
|
+
return this.request('POST', `/repos/${repo}/pulls`, { title, body, head, base });
|
|
352
|
+
}
|
|
353
|
+
}
|
package/src/bot.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Bot 实现
|
|
3
|
+
*/
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import {
|
|
7
|
+
Bot,
|
|
8
|
+
Message,
|
|
9
|
+
SendOptions,
|
|
10
|
+
SendContent,
|
|
11
|
+
segment,
|
|
12
|
+
type MessageSegment,
|
|
13
|
+
} from 'zhin.js';
|
|
14
|
+
import type {
|
|
15
|
+
GitHubBotConfig,
|
|
16
|
+
IssueCommentPayload,
|
|
17
|
+
PRReviewCommentPayload,
|
|
18
|
+
PRReviewPayload,
|
|
19
|
+
} from './types.js';
|
|
20
|
+
import { buildChannelId, parseChannelId } from './types.js';
|
|
21
|
+
import type { GitHubAdapter } from './adapter.js';
|
|
22
|
+
import { GitHubAPI } from './api.js';
|
|
23
|
+
|
|
24
|
+
function resolvePrivateKey(raw: string): string {
|
|
25
|
+
if (raw.includes('-----BEGIN')) return raw;
|
|
26
|
+
const resolved = path.resolve(raw);
|
|
27
|
+
if (fs.existsSync(resolved)) return fs.readFileSync(resolved, 'utf-8');
|
|
28
|
+
throw new Error(`private_key 既不是 PEM 内容也不是有效的文件路径: ${raw}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function parseMarkdown(md: string): MessageSegment[] {
|
|
32
|
+
const segments: MessageSegment[] = [];
|
|
33
|
+
const mentionRe = /@(\w[-\w]*)/g;
|
|
34
|
+
let lastIdx = 0;
|
|
35
|
+
let match: RegExpExecArray | null;
|
|
36
|
+
while ((match = mentionRe.exec(md)) !== null) {
|
|
37
|
+
if (match.index > lastIdx) segments.push({ type: 'text', data: { text: md.slice(lastIdx, match.index) } });
|
|
38
|
+
segments.push({ type: 'at', data: { id: match[1], name: match[1], text: match[0] } });
|
|
39
|
+
lastIdx = match.index + match[0].length;
|
|
40
|
+
}
|
|
41
|
+
if (lastIdx < md.length) segments.push({ type: 'text', data: { text: md.slice(lastIdx) } });
|
|
42
|
+
return segments.length ? segments : [{ type: 'text', data: { text: md } }];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function toMarkdown(content: SendContent): string {
|
|
46
|
+
if (!Array.isArray(content)) content = [content];
|
|
47
|
+
return content.map(seg => {
|
|
48
|
+
if (typeof seg === 'string') return seg;
|
|
49
|
+
switch (seg.type) {
|
|
50
|
+
case 'text': return seg.data.text || '';
|
|
51
|
+
case 'at': return `@${seg.data.name || seg.data.id}`;
|
|
52
|
+
case 'image': return seg.data.url ? `` : '[image]';
|
|
53
|
+
case 'link': return `[${seg.data.text || seg.data.url}](${seg.data.url})`;
|
|
54
|
+
default: return seg.data?.text || `[${seg.type}]`;
|
|
55
|
+
}
|
|
56
|
+
}).join('');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class GitHubBot implements Bot<GitHubBotConfig, IssueCommentPayload> {
|
|
60
|
+
$connected = false;
|
|
61
|
+
api: GitHubAPI;
|
|
62
|
+
|
|
63
|
+
get $id() { return this.$config.name; }
|
|
64
|
+
|
|
65
|
+
get logger() {
|
|
66
|
+
return this.adapter.plugin.logger;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
constructor(public adapter: GitHubAdapter, public $config: GitHubBotConfig) {
|
|
70
|
+
const privateKey = resolvePrivateKey($config.private_key);
|
|
71
|
+
this.api = new GitHubAPI($config.app_id, privateKey, $config.installation_id);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async $connect(): Promise<void> {
|
|
75
|
+
const result = await this.api.verifyAuth();
|
|
76
|
+
if (!result.ok) throw new Error(`GitHub 认证失败: ${result.message}`);
|
|
77
|
+
this.$connected = true;
|
|
78
|
+
this.logger.info(`GitHub bot ${this.$id} 已连接 — ${result.message}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async $disconnect(): Promise<void> {
|
|
82
|
+
this.$connected = false;
|
|
83
|
+
this.logger.info(`GitHub bot ${this.$id} 已断开`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
$formatMessage(payload: IssueCommentPayload): Message<IssueCommentPayload> {
|
|
87
|
+
const repo = payload.repository.full_name;
|
|
88
|
+
const number = payload.issue.number;
|
|
89
|
+
const isPR = 'pull_request' in (payload.issue as any);
|
|
90
|
+
const channelId = buildChannelId(repo, isPR ? 'pr' : 'issue', number);
|
|
91
|
+
const api = this.api;
|
|
92
|
+
|
|
93
|
+
const result = Message.from(payload, {
|
|
94
|
+
$id: payload.comment.id.toString(),
|
|
95
|
+
$adapter: 'github',
|
|
96
|
+
$bot: this.$config.name,
|
|
97
|
+
$sender: { id: payload.sender.login, name: payload.sender.login },
|
|
98
|
+
$channel: { id: channelId, type: 'group' },
|
|
99
|
+
$content: parseMarkdown(payload.comment.body),
|
|
100
|
+
$raw: payload.comment.body,
|
|
101
|
+
$timestamp: new Date(payload.comment.created_at).getTime(),
|
|
102
|
+
$recall: async () => { await api.deleteIssueComment(repo, payload.comment.id); },
|
|
103
|
+
$reply: async (content: SendContent, quote?: boolean | string): Promise<string> => {
|
|
104
|
+
const text = toMarkdown(content);
|
|
105
|
+
const finalBody = quote ? `> ${payload.comment.body.split('\n')[0]}\n\n${text}` : text;
|
|
106
|
+
const r = await api.createIssueComment(repo, number, finalBody);
|
|
107
|
+
return r.ok ? r.data.id.toString() : '';
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
formatPRReviewComment(payload: PRReviewCommentPayload): Message<PRReviewCommentPayload> {
|
|
114
|
+
const repo = payload.repository.full_name;
|
|
115
|
+
const number = payload.pull_request.number;
|
|
116
|
+
const channelId = buildChannelId(repo, 'pr', number);
|
|
117
|
+
const api = this.api;
|
|
118
|
+
|
|
119
|
+
const body = payload.comment.path
|
|
120
|
+
? `**${payload.comment.path}**\n${payload.comment.diff_hunk ? '```diff\n' + payload.comment.diff_hunk + '\n```\n' : ''}${payload.comment.body}`
|
|
121
|
+
: payload.comment.body;
|
|
122
|
+
|
|
123
|
+
return Message.from(payload, {
|
|
124
|
+
$id: payload.comment.id.toString(),
|
|
125
|
+
$adapter: 'github',
|
|
126
|
+
$bot: this.$config.name,
|
|
127
|
+
$sender: { id: payload.sender.login, name: payload.sender.login },
|
|
128
|
+
$channel: { id: channelId, type: 'group' },
|
|
129
|
+
$content: parseMarkdown(body),
|
|
130
|
+
$raw: body,
|
|
131
|
+
$timestamp: new Date(payload.comment.created_at).getTime(),
|
|
132
|
+
$recall: async () => { await api.deletePRReviewComment(repo, payload.comment.id); },
|
|
133
|
+
$reply: async (content: SendContent): Promise<string> => {
|
|
134
|
+
const r = await api.createPRComment(repo, number, toMarkdown(content));
|
|
135
|
+
return r.ok ? r.data.id.toString() : '';
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
formatPRReview(payload: PRReviewPayload): Message<PRReviewPayload> | null {
|
|
141
|
+
if (!payload.review.body) return null;
|
|
142
|
+
const repo = payload.repository.full_name;
|
|
143
|
+
const number = payload.pull_request.number;
|
|
144
|
+
const channelId = buildChannelId(repo, 'pr', number);
|
|
145
|
+
const api = this.api;
|
|
146
|
+
|
|
147
|
+
const stateLabel: Record<string, string> = {
|
|
148
|
+
approved: '✅ APPROVED', changes_requested: '🔄 CHANGES REQUESTED',
|
|
149
|
+
commented: '💬 COMMENTED', dismissed: '❌ DISMISSED',
|
|
150
|
+
};
|
|
151
|
+
const body = `**[${stateLabel[payload.review.state] || payload.review.state}]**\n${payload.review.body}`;
|
|
152
|
+
|
|
153
|
+
return Message.from(payload, {
|
|
154
|
+
$id: payload.review.id.toString(),
|
|
155
|
+
$adapter: 'github',
|
|
156
|
+
$bot: this.$config.name,
|
|
157
|
+
$sender: { id: payload.sender.login, name: payload.sender.login },
|
|
158
|
+
$channel: { id: channelId, type: 'group' },
|
|
159
|
+
$content: parseMarkdown(body),
|
|
160
|
+
$raw: body,
|
|
161
|
+
$timestamp: new Date(payload.review.submitted_at).getTime(),
|
|
162
|
+
$recall: async () => {},
|
|
163
|
+
$reply: async (content: SendContent): Promise<string> => {
|
|
164
|
+
const r = await api.createPRComment(repo, number, toMarkdown(content));
|
|
165
|
+
return r.ok ? r.data.id.toString() : '';
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async $sendMessage(options: SendOptions): Promise<string> {
|
|
171
|
+
const parsed = parseChannelId(options.id);
|
|
172
|
+
if (!parsed) throw new Error(`无效的 GitHub channel ID: ${options.id}`);
|
|
173
|
+
|
|
174
|
+
const text = toMarkdown(options.content);
|
|
175
|
+
const r = parsed.type === 'issue'
|
|
176
|
+
? await this.api.createIssueComment(parsed.repo, parsed.number, text)
|
|
177
|
+
: await this.api.createPRComment(parsed.repo, parsed.number, text);
|
|
178
|
+
if (!r.ok) throw new Error(`发送失败: ${JSON.stringify(r.data)}`);
|
|
179
|
+
|
|
180
|
+
this.logger.debug(`${this.$id} send → ${options.id}: ${text.slice(0, 80)}...`);
|
|
181
|
+
return r.data.id.toString();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async $recallMessage(id: string): Promise<void> {
|
|
185
|
+
this.logger.warn('$recallMessage 需要 repo 信息,请使用 message.$recall()');
|
|
186
|
+
}
|
|
187
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub 适配器入口:类型扩展、模型、导出、注册
|
|
3
|
+
*/
|
|
4
|
+
import { usePlugin, type Plugin, type Context } from 'zhin.js';
|
|
5
|
+
import { GitHubAdapter } from './adapter.js';
|
|
6
|
+
|
|
7
|
+
declare module 'zhin.js' {
|
|
8
|
+
namespace Plugin {
|
|
9
|
+
interface Contexts {
|
|
10
|
+
router: import('@zhin.js/http').Router;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
interface Adapters {
|
|
14
|
+
github: GitHubAdapter;
|
|
15
|
+
}
|
|
16
|
+
interface Models {
|
|
17
|
+
github_subscriptions: {
|
|
18
|
+
id: number;
|
|
19
|
+
repo: string;
|
|
20
|
+
events: import('./types.js').EventType[];
|
|
21
|
+
target_id: string;
|
|
22
|
+
target_type: 'private' | 'group' | 'channel';
|
|
23
|
+
adapter: string;
|
|
24
|
+
bot: string;
|
|
25
|
+
};
|
|
26
|
+
github_events: {
|
|
27
|
+
id: number;
|
|
28
|
+
repo: string;
|
|
29
|
+
event_type: string;
|
|
30
|
+
payload: any;
|
|
31
|
+
};
|
|
32
|
+
github_oauth_users: import('./types.js').GitHubOAuthUser;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export * from './types.js';
|
|
37
|
+
export { GitHubBot, parseMarkdown, toMarkdown } from './bot.js';
|
|
38
|
+
export { GitHubAdapter } from './adapter.js';
|
|
39
|
+
|
|
40
|
+
const plugin = usePlugin();
|
|
41
|
+
const { provide, defineModel, useContext, logger } = plugin;
|
|
42
|
+
|
|
43
|
+
defineModel('github_subscriptions', {
|
|
44
|
+
id: { type: 'integer', primary: true },
|
|
45
|
+
repo: { type: 'text', nullable: false },
|
|
46
|
+
events: { type: 'json', default: [] },
|
|
47
|
+
target_id: { type: 'text', nullable: false },
|
|
48
|
+
target_type: { type: 'text', nullable: false },
|
|
49
|
+
adapter: { type: 'text', nullable: false },
|
|
50
|
+
bot: { type: 'text', nullable: false },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
defineModel('github_events', {
|
|
54
|
+
id: { type: 'integer', primary: true },
|
|
55
|
+
repo: { type: 'text', nullable: false },
|
|
56
|
+
event_type: { type: 'text', nullable: false },
|
|
57
|
+
payload: { type: 'json', default: {} },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
defineModel('github_oauth_users', {
|
|
61
|
+
id: { type: 'integer', primary: true },
|
|
62
|
+
platform: { type: 'text', nullable: false },
|
|
63
|
+
platform_uid: { type: 'text', nullable: false },
|
|
64
|
+
github_login: { type: 'text', nullable: false },
|
|
65
|
+
github_id: { type: 'integer', nullable: false },
|
|
66
|
+
access_token: { type: 'text', nullable: false },
|
|
67
|
+
scope: { type: 'text', default: '' },
|
|
68
|
+
created_at: { type: 'date', nullable: false },
|
|
69
|
+
updated_at: { type: 'date', nullable: false },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
provide({
|
|
73
|
+
name: 'github',
|
|
74
|
+
description: 'GitHub Adapter — Issues/PRs as chat channels, full repo management via GitHub App',
|
|
75
|
+
mounted: async (p: Plugin) => {
|
|
76
|
+
const adapter = new GitHubAdapter(p);
|
|
77
|
+
await adapter.start();
|
|
78
|
+
return adapter;
|
|
79
|
+
},
|
|
80
|
+
dispose: async (adapter: GitHubAdapter) => {
|
|
81
|
+
await adapter.stop();
|
|
82
|
+
},
|
|
83
|
+
} as Context<'github'>);
|
|
84
|
+
|
|
85
|
+
useContext('router', 'github', (router, adapter) => {
|
|
86
|
+
adapter.setupWebhook(router);
|
|
87
|
+
adapter.setupOAuth(router);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
logger.debug('GitHub 适配器已加载 (GitHub App 认证)');
|