@zhin.js/adapter-github 0.1.35 → 0.1.37

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.
@@ -0,0 +1,640 @@
1
+ /**
2
+ * GitHub CLI (`gh`) 客户端封装
3
+ *
4
+ * 支持三种认证方式:
5
+ * 1. gh CLI 默认凭据(gh auth login)
6
+ * 2. GitHub App — JWT → Installation Token(自动续期)
7
+ * 3. 个人 OAuth Token(用户绑定 / Device Flow)
8
+ *
9
+ * 零外部依赖,使用 Node.js 原生模块。
10
+ */
11
+
12
+ import { spawn } from 'node:child_process';
13
+ import crypto from 'node:crypto';
14
+ import fs from 'node:fs';
15
+
16
+ // ── GitHub App 认证配置 ──────────────────────────────────────────────
17
+
18
+ export interface AppAuth {
19
+ appId: string | number;
20
+ /** PEM 格式私钥内容,或私钥文件路径 */
21
+ privateKey: string;
22
+ }
23
+
24
+ export interface GhClientOptions {
25
+ /** GitHub Enterprise 主机名(默认 github.com) */
26
+ host?: string;
27
+ /** 覆盖 gh CLI 默认认证的 Personal Access Token / OAuth Token */
28
+ token?: string;
29
+ /** GitHub App 认证 — 自动管理 Installation Token */
30
+ appAuth?: AppAuth;
31
+ }
32
+
33
+ export class GhClient {
34
+ private host?: string;
35
+ private token?: string;
36
+ private _appAuth?: AppAuth;
37
+ private _resolvedKey?: string;
38
+ private _user: string | null = null;
39
+ /** Installation Token 缓存: installationId → { token, expiresAt } */
40
+ private _installationTokens = new Map<number, { token: string; expiresAt: number }>();
41
+ /** repo(小写) → installationId */
42
+ private _repoToInstallation = new Map<string, number>();
43
+ /** 所有 Installation 信息 */
44
+ private _allInstallations: Array<{ id: number; account: { login: string; type: string }; target_type: string }> = [];
45
+ /** 上次 syncInstallations 时间 (ms) */
46
+ private _lastSyncTime = 0;
47
+
48
+ constructor(options: GhClientOptions = {}) {
49
+ this.host = options.host;
50
+ this.token = options.token;
51
+ this._appAuth = options.appAuth;
52
+ }
53
+
54
+ /** 创建使用指定 token 的副本(共享 host 配置,不继承 App 认证) */
55
+ withToken(token: string): GhClient {
56
+ return new GhClient({ host: this.host, token });
57
+ }
58
+
59
+ get authenticatedUser() { return this._user; }
60
+
61
+ // ── 底层执行 ──────────────────────────────────────────────────────
62
+
63
+ private exec(args: string[], stdin?: string): Promise<string> {
64
+ const fullArgs = this.host ? [...args, '--hostname', this.host] : args;
65
+ const env = this.token
66
+ ? { ...process.env, GH_TOKEN: this.token }
67
+ : undefined;
68
+ return new Promise((resolve, reject) => {
69
+ const proc = spawn('gh', fullArgs, { stdio: ['pipe', 'pipe', 'pipe'], env });
70
+ let stdout = '';
71
+ let stderr = '';
72
+ proc.stdout.on('data', (d: Buffer) => (stdout += d.toString()));
73
+ proc.stderr.on('data', (d: Buffer) => (stderr += d.toString()));
74
+ proc.on('error', (err: NodeJS.ErrnoException) => {
75
+ if (err.code === 'ENOENT') reject(new Error('gh CLI 未安装,请先安装: https://cli.github.com/'));
76
+ else reject(err);
77
+ });
78
+ proc.on('close', (code) => {
79
+ if (code === 0) resolve(stdout);
80
+ else
81
+ reject(
82
+ Object.assign(new Error(stderr.trim() || `gh exited with code ${code}`), {
83
+ stdout,
84
+ stderr,
85
+ exitCode: code,
86
+ }),
87
+ );
88
+ });
89
+ if (stdin !== undefined) proc.stdin.write(stdin);
90
+ proc.stdin.end();
91
+ });
92
+ }
93
+
94
+ // ── API 调用 ──────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * 确保 App 认证的 Installation Token 有效(自动按 repo 查找对应安装)
98
+ * @param repo 仓库全名(owner/repo),用于选择正确的 Installation
99
+ */
100
+ private async ensureTokenForRepo(repo?: string): Promise<void> {
101
+ if (!this._appAuth) return;
102
+ let installId: number | undefined;
103
+ if (repo) {
104
+ const key = repo.toLowerCase();
105
+ installId = this._repoToInstallation.get(key);
106
+ if (!installId) {
107
+ // Owner 级别匹配(安装在整个账号/组织上)
108
+ const owner = key.split('/')[0];
109
+ for (const inst of this._allInstallations) {
110
+ if (inst.account.login.toLowerCase() === owner) {
111
+ installId = inst.id;
112
+ break;
113
+ }
114
+ }
115
+ }
116
+ // 懒加载重新发现(5 分钟冷却)
117
+ if (!installId && Date.now() - this._lastSyncTime > 5 * 60_000) {
118
+ await this.syncInstallations();
119
+ installId = this._repoToInstallation.get(key);
120
+ if (!installId) {
121
+ const owner = key.split('/')[0];
122
+ for (const inst of this._allInstallations) {
123
+ if (inst.account.login.toLowerCase() === owner) {
124
+ installId = inst.id;
125
+ break;
126
+ }
127
+ }
128
+ }
129
+ }
130
+ }
131
+ // 回退到第一个安装
132
+ if (!installId && this._allInstallations.length) {
133
+ installId = this._allInstallations[0].id;
134
+ }
135
+ if (!installId) return;
136
+ const cached = this._installationTokens.get(installId);
137
+ if (cached && cached.expiresAt > Date.now() + 60_000) {
138
+ this.token = cached.token;
139
+ return;
140
+ }
141
+ await this.refreshInstallationToken(installId);
142
+ }
143
+
144
+ async request<T = any>(
145
+ method: string,
146
+ path: string,
147
+ body?: any,
148
+ headers?: Record<string, string>,
149
+ ): Promise<{ ok: boolean; status: number; data: T }> {
150
+ // 从路径自动检测 repo 以选择正确的 Installation Token
151
+ const repoMatch = path.match(/^\/repos\/([^/?]+\/[^/?]+)/);
152
+ await this.ensureTokenForRepo(repoMatch?.[1]);
153
+ const args = ['api', path, '--method', method];
154
+ if (body) args.push('--input', '-');
155
+ if (headers) {
156
+ for (const [k, v] of Object.entries(headers)) {
157
+ args.push('-H', `${k}: ${v}`);
158
+ }
159
+ }
160
+ try {
161
+ const raw = await this.exec(args, body ? JSON.stringify(body) : undefined);
162
+ let data: any;
163
+ try { data = raw ? JSON.parse(raw) : null; } catch { data = raw; }
164
+ return { ok: true, status: 200, data };
165
+ } catch (err: any) {
166
+ let data: any;
167
+ try { data = err.stdout ? JSON.parse(err.stdout) : { message: err.message }; } catch {
168
+ data = { message: err.stderr?.trim() || err.message };
169
+ }
170
+ return { ok: false, status: err.exitCode || 0, data };
171
+ }
172
+ }
173
+
174
+ private get<T = any>(path: string) { return this.request<T>('GET', path); }
175
+ private post<T = any>(path: string, body?: any) { return this.request<T>('POST', path, body); }
176
+ private patch<T = any>(path: string, body?: any) { return this.request<T>('PATCH', path, body); }
177
+ private put<T = any>(path: string, body?: any) { return this.request<T>('PUT', path, body); }
178
+ private del<T = any>(path: string, body?: any) { return this.request<T>('DELETE', path, body); }
179
+
180
+ // ── 认证验证 ──────────────────────────────────────────────────────
181
+
182
+ async verifyAuth(): Promise<{ ok: boolean; user: string; message: string }> {
183
+ // App 认证模式:自动发现所有安装
184
+ if (this._appAuth) {
185
+ try {
186
+ await this.syncInstallations();
187
+ if (!this._allInstallations.length) {
188
+ return { ok: false, user: '', message: 'GitHub App 没有任何安装,请先在 GitHub 上安装 App' };
189
+ }
190
+ // 用 JWT 查询 App 信息(/app 端点只接受 JWT 认证,不能用 Installation Token)
191
+ const key = this.resolvePrivateKey();
192
+ const jwt = GhClient.createJWT(this._appAuth.appId, key);
193
+ const baseUrl = this.host ? `https://${this.host}/api/v3` : 'https://api.github.com';
194
+ const appRes = await fetch(`${baseUrl}/app`, {
195
+ headers: { Authorization: `Bearer ${jwt}`, Accept: 'application/vnd.github+json' },
196
+ });
197
+ if (appRes.ok) {
198
+ const appData = (await appRes.json()) as { name: string; slug: string; client_id?: string };
199
+ const name = `${appData.slug || appData.name}[bot]`;
200
+ this._user = name;
201
+ this._appSlug = appData.slug || null;
202
+ if (appData.client_id) this._clientId = appData.client_id;
203
+ const repos = this._repoToInstallation.size;
204
+ return { ok: true, user: name, message: `GitHub App: ${name} (${this._allInstallations.length} 安装, ${repos} 仓库)` };
205
+ }
206
+ const errBody = await appRes.text();
207
+ return { ok: false, user: '', message: `App Token 验证失败 (${appRes.status}): ${errBody}` };
208
+ } catch (e: any) {
209
+ return { ok: false, user: '', message: e.message || 'App 认证失败' };
210
+ }
211
+ }
212
+ // Token 模式(用户绑定)或 gh CLI 默认模式
213
+ try {
214
+ if (this.token) {
215
+ // 直接用 token 查 /user
216
+ const r = await this.get<{ login: string }>('/user');
217
+ if (r.ok) {
218
+ this._user = r.data.login;
219
+ return { ok: true, user: r.data.login, message: `token: ${r.data.login}` };
220
+ }
221
+ return { ok: false, user: '', message: 'Token 无效' };
222
+ }
223
+ await this.exec(['auth', 'status']);
224
+ const raw = await this.exec(['api', '/user', '--jq', '.login']);
225
+ const login = raw.trim();
226
+ this._user = login;
227
+ return { ok: true, user: login, message: `gh CLI: ${login}` };
228
+ } catch (e: any) {
229
+ return { ok: false, user: '', message: e.message || 'gh 认证检查失败' };
230
+ }
231
+ }
232
+
233
+ // ── Issue 评论 (聊天核心) ─────────────────────────────────────────
234
+
235
+ async createIssueComment(repo: string, issueNumber: number, body: string) {
236
+ return this.post<{ id: number; html_url: string }>(`/repos/${repo}/issues/${issueNumber}/comments`, { body });
237
+ }
238
+
239
+ async deleteIssueComment(repo: string, commentId: number) {
240
+ return this.del(`/repos/${repo}/issues/comments/${commentId}`);
241
+ }
242
+
243
+ async createPRComment(repo: string, prNumber: number, body: string) {
244
+ return this.createIssueComment(repo, prNumber, body);
245
+ }
246
+
247
+ async deletePRReviewComment(repo: string, commentId: number) {
248
+ return this.del(`/repos/${repo}/pulls/comments/${commentId}`);
249
+ }
250
+
251
+ // ── Pull Requests ─────────────────────────────────────────────────
252
+
253
+ async listPRs(repo: string, state: string = 'open', limit: number = 15) {
254
+ return this.get<any[]>(`/repos/${repo}/pulls?state=${state}&per_page=${limit}`);
255
+ }
256
+
257
+ async getPR(repo: string, number: number) {
258
+ return this.get<any>(`/repos/${repo}/pulls/${number}`);
259
+ }
260
+
261
+ async getPRDiff(repo: string, number: number): Promise<{ ok: boolean; data: string }> {
262
+ return this.request('GET', `/repos/${repo}/pulls/${number}`, undefined, {
263
+ Accept: 'application/vnd.github.diff',
264
+ }) as Promise<{ ok: boolean; data: string }>;
265
+ }
266
+
267
+ async mergePR(repo: string, number: number, method: string = 'squash') {
268
+ return this.put<any>(`/repos/${repo}/pulls/${number}/merge`, { merge_method: method });
269
+ }
270
+
271
+ async createPR(repo: string, title: string, body: string, head: string, base: string = 'main') {
272
+ return this.post<any>(`/repos/${repo}/pulls`, { title, body, head, base });
273
+ }
274
+
275
+ async createPRReview(repo: string, number: number, event: 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT', body?: string) {
276
+ return this.post<any>(`/repos/${repo}/pulls/${number}/reviews`, { event, body: body || '' });
277
+ }
278
+
279
+ async closePR(repo: string, number: number) {
280
+ return this.patch<any>(`/repos/${repo}/pulls/${number}`, { state: 'closed' });
281
+ }
282
+
283
+ // ── Issues ────────────────────────────────────────────────────────
284
+
285
+ async listIssues(repo: string, state: string = 'open', limit: number = 15) {
286
+ return this.get<any[]>(`/repos/${repo}/issues?state=${state}&per_page=${limit}&direction=desc`);
287
+ }
288
+
289
+ async getIssue(repo: string, number: number) {
290
+ return this.get<any>(`/repos/${repo}/issues/${number}`);
291
+ }
292
+
293
+ async createIssue(repo: string, title: string, body?: string, labels?: string[]) {
294
+ return this.post<any>(`/repos/${repo}/issues`, { title, body, labels });
295
+ }
296
+
297
+ async closeIssue(repo: string, number: number) {
298
+ return this.patch<any>(`/repos/${repo}/issues/${number}`, { state: 'closed', state_reason: 'completed' });
299
+ }
300
+
301
+ // ── Repository ────────────────────────────────────────────────────
302
+
303
+ async getRepo(repo: string) {
304
+ return this.get<any>(`/repos/${repo}`);
305
+ }
306
+
307
+ async listBranches(repo: string, limit: number = 30) {
308
+ return this.get<any[]>(`/repos/${repo}/branches?per_page=${limit}`);
309
+ }
310
+
311
+ async listReleases(repo: string, limit: number = 10) {
312
+ return this.get<any[]>(`/repos/${repo}/releases?per_page=${limit}`);
313
+ }
314
+
315
+ async listWorkflowRuns(repo: string, limit: number = 10) {
316
+ return this.get<{ total_count: number; workflow_runs: any[] }>(`/repos/${repo}/actions/runs?per_page=${limit}`);
317
+ }
318
+
319
+ // ── Search ───────────────────────────────────────────────────────
320
+
321
+ async searchIssues(query: string, limit: number = 15) {
322
+ return this.get<{ total_count: number; items: any[] }>(`/search/issues?q=${encodeURIComponent(query)}&per_page=${limit}`);
323
+ }
324
+
325
+ async searchRepos(query: string, limit: number = 15) {
326
+ return this.get<{ total_count: number; items: any[] }>(`/search/repositories?q=${encodeURIComponent(query)}&per_page=${limit}`);
327
+ }
328
+
329
+ async searchCode(query: string, limit: number = 15) {
330
+ return this.get<{ total_count: number; items: any[] }>(`/search/code?q=${encodeURIComponent(query)}&per_page=${limit}`);
331
+ }
332
+
333
+ // ── Labels ───────────────────────────────────────────────────────
334
+
335
+ async listLabels(repo: string) {
336
+ return this.get<any[]>(`/repos/${repo}/labels?per_page=100`);
337
+ }
338
+
339
+ async addLabels(repo: string, issueNumber: number, labels: string[]) {
340
+ return this.post<any[]>(`/repos/${repo}/issues/${issueNumber}/labels`, { labels });
341
+ }
342
+
343
+ async removeLabel(repo: string, issueNumber: number, label: string) {
344
+ return this.del(`/repos/${repo}/issues/${issueNumber}/labels/${encodeURIComponent(label)}`);
345
+ }
346
+
347
+ // ── Assignees ────────────────────────────────────────────────────
348
+
349
+ async addAssignees(repo: string, issueNumber: number, assignees: string[]) {
350
+ return this.post<any>(`/repos/${repo}/issues/${issueNumber}/assignees`, { assignees });
351
+ }
352
+
353
+ async removeAssignees(repo: string, issueNumber: number, assignees: string[]) {
354
+ return this.del<any>(`/repos/${repo}/issues/${issueNumber}/assignees`, { assignees });
355
+ }
356
+
357
+ // ── File Content ─────────────────────────────────────────────────
358
+
359
+ async getFileContent(repo: string, filePath: string, ref?: string) {
360
+ const qs = ref ? `?ref=${encodeURIComponent(ref)}` : '';
361
+ return this.get<any>(`/repos/${repo}/contents/${filePath}${qs}`);
362
+ }
363
+
364
+ // ── Commits ──────────────────────────────────────────────────────
365
+
366
+ async listCommits(repo: string, sha?: string, filePath?: string, limit: number = 15) {
367
+ const params = new URLSearchParams({ per_page: String(limit) });
368
+ if (sha) params.set('sha', sha);
369
+ if (filePath) params.set('path', filePath);
370
+ return this.get<any[]>(`/repos/${repo}/commits?${params}`);
371
+ }
372
+
373
+ async compareCommits(repo: string, base: string, head: string) {
374
+ return this.get<any>(`/repos/${repo}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`);
375
+ }
376
+
377
+ // ── Update Issue / PR ────────────────────────────────────────────
378
+
379
+ async updateIssue(repo: string, number: number, data: { title?: string; body?: string; state?: string; labels?: string[]; assignees?: string[] }) {
380
+ return this.patch<any>(`/repos/${repo}/issues/${number}`, data);
381
+ }
382
+
383
+ async updatePR(repo: string, number: number, data: { title?: string; body?: string; state?: string; base?: string }) {
384
+ return this.patch<any>(`/repos/${repo}/pulls/${number}`, data);
385
+ }
386
+
387
+ // ── Star ─────────────────────────────────────────────────────────
388
+
389
+ async starRepo(repo: string) {
390
+ return this.put(`/user/starred/${repo}`);
391
+ }
392
+
393
+ async unstarRepo(repo: string) {
394
+ return this.del(`/user/starred/${repo}`);
395
+ }
396
+
397
+ async isStarred(repo: string): Promise<boolean> {
398
+ const r = await this.request('GET', `/user/starred/${repo}`);
399
+ return r.ok;
400
+ }
401
+
402
+ // ── Fork ─────────────────────────────────────────────────────────
403
+
404
+ async forkRepo(repo: string) {
405
+ return this.post(`/repos/${repo}/forks`);
406
+ }
407
+
408
+ // ── GitHub App JWT + Installation Token ──────────────────────────
409
+
410
+ /** 解析私钥:PEM字符串 或 文件路径 */
411
+ private resolvePrivateKey(): string {
412
+ if (this._resolvedKey) return this._resolvedKey;
413
+ if (!this._appAuth) throw new Error('未配置 App 认证');
414
+ let key = this._appAuth.privateKey;
415
+ if (!key.includes('-----BEGIN')) {
416
+ // 当作文件路径读取
417
+ key = fs.readFileSync(key, 'utf-8');
418
+ }
419
+ this._resolvedKey = key;
420
+ return key;
421
+ }
422
+
423
+ /** 生成 GitHub App JWT(有效期 10 分钟) */
424
+ static createJWT(appId: string | number, privateKey: string): string {
425
+ const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
426
+ const now = Math.floor(Date.now() / 1000);
427
+ const payload = Buffer.from(JSON.stringify({
428
+ iat: now - 60,
429
+ exp: now + 600,
430
+ iss: String(appId),
431
+ })).toString('base64url');
432
+ const signature = crypto
433
+ .sign('sha256', Buffer.from(`${header}.${payload}`), privateKey)
434
+ .toString('base64url');
435
+ return `${header}.${payload}.${signature}`;
436
+ }
437
+
438
+ /** 使用 JWT 获取指定 Installation 的 Access Token(有效期 1 小时) */
439
+ async refreshInstallationToken(installationId: number): Promise<void> {
440
+ if (!this._appAuth) throw new Error('未配置 App 认证');
441
+ const key = this.resolvePrivateKey();
442
+ const jwt = GhClient.createJWT(this._appAuth.appId, key);
443
+ const baseUrl = this.host
444
+ ? `https://${this.host}/api/v3`
445
+ : 'https://api.github.com';
446
+ const res = await fetch(
447
+ `${baseUrl}/app/installations/${installationId}/access_tokens`,
448
+ {
449
+ method: 'POST',
450
+ headers: {
451
+ Authorization: `Bearer ${jwt}`,
452
+ Accept: 'application/vnd.github+json',
453
+ },
454
+ },
455
+ );
456
+ if (!res.ok) {
457
+ const body = await res.text();
458
+ throw new Error(`Installation Token 获取失败 (${res.status}): ${body}`);
459
+ }
460
+ const data = (await res.json()) as { token: string; expires_at: string };
461
+ this.token = data.token;
462
+ this._installationTokens.set(installationId, {
463
+ token: data.token,
464
+ expiresAt: new Date(data.expires_at).getTime(),
465
+ });
466
+ }
467
+
468
+ /** 发现所有 App 安装及其可访问的仓库 */
469
+ async syncInstallations(): Promise<void> {
470
+ if (!this._appAuth) return;
471
+ const key = this.resolvePrivateKey();
472
+ const jwt = GhClient.createJWT(this._appAuth.appId, key);
473
+ const baseUrl = this.host ? `https://${this.host}/api/v3` : 'https://api.github.com';
474
+ const listRes = await fetch(`${baseUrl}/app/installations`, {
475
+ headers: { Authorization: `Bearer ${jwt}`, Accept: 'application/vnd.github+json' },
476
+ });
477
+ if (!listRes.ok) {
478
+ const errBody = await listRes.text();
479
+ throw new Error(`列出安装失败: ${listRes.status} - ${errBody}`);
480
+ }
481
+ const installations = (await listRes.json()) as Array<{
482
+ id: number; account: { login: string; type: string }; target_type: string;
483
+ }>;
484
+ this._allInstallations = installations;
485
+ this._repoToInstallation.clear();
486
+ for (const inst of installations) {
487
+ try {
488
+ await this.refreshInstallationToken(inst.id);
489
+ const cached = this._installationTokens.get(inst.id);
490
+ if (!cached) continue;
491
+ const repoRes = await fetch(`${baseUrl}/installation/repositories?per_page=100`, {
492
+ headers: { Authorization: `Bearer ${cached.token}`, Accept: 'application/vnd.github+json' },
493
+ });
494
+ if (repoRes.ok) {
495
+ const data = (await repoRes.json()) as { repositories: Array<{ full_name: string }> };
496
+ for (const repo of data.repositories) {
497
+ this._repoToInstallation.set(repo.full_name.toLowerCase(), inst.id);
498
+ }
499
+ }
500
+ } catch {
501
+ // 单个安装失败不影响其他
502
+ }
503
+ }
504
+ this._lastSyncTime = Date.now();
505
+ }
506
+
507
+ /** 当前是否使用 App 认证 */
508
+ get isAppAuth(): boolean { return !!this._appAuth; }
509
+
510
+ /** 所有已发现的安装 */
511
+ get installations() { return this._allInstallations; }
512
+
513
+ /** App 的 slug(verifyAuth 后可用) */
514
+ private _appSlug: string | null = null;
515
+ get appSlug(): string | null { return this._appSlug; }
516
+
517
+ /** App 的 client_id(verifyAuth 后从 /app 获取,用于 Device Flow) */
518
+ private _clientId: string | null = null;
519
+ get clientId(): string | null { return this._clientId; }
520
+
521
+ // ── 事件轮询 ─────────────────────────────────────────────────────
522
+
523
+ /**
524
+ * 获取仓库事件(支持 ETag 条件请求)
525
+ * @returns events 数组 + 新 etag;若 304 未修改则 events 为空
526
+ */
527
+ async listRepoEvents(
528
+ repo: string,
529
+ etag?: string,
530
+ ): Promise<{ events: any[]; etag: string | null }> {
531
+ await this.ensureTokenForRepo(repo);
532
+ const args = ['api', `/repos/${repo}/events?per_page=30`, '--method', 'GET', '--include'];
533
+ if (etag) args.push('-H', `If-None-Match: ${etag}`);
534
+ try {
535
+ const raw = await this.exec(args);
536
+ // --include 输出包含 HTTP 头 + 空行 + body
537
+ const sepIdx = raw.indexOf('\r\n\r\n');
538
+ const headerPart = sepIdx >= 0 ? raw.slice(0, sepIdx) : '';
539
+ const bodyPart = sepIdx >= 0 ? raw.slice(sepIdx + 4) : raw;
540
+ const newEtag = headerPart.match(/etag:\s*"?([^"\r\n]+)"?/i)?.[1] || null;
541
+ let events: any[];
542
+ try { events = JSON.parse(bodyPart); } catch { events = []; }
543
+ return { events: Array.isArray(events) ? events : [], etag: newEtag };
544
+ } catch (err: any) {
545
+ // 304 Not Modified — gh 会以非 0 退出码返回
546
+ if (err.stderr?.includes('304') || err.exitCode === 1) {
547
+ return { events: [], etag: etag || null };
548
+ }
549
+ throw err;
550
+ }
551
+ }
552
+
553
+ // ── Device Flow OAuth(多用户绑定) ──────────────────────────────
554
+
555
+ /**
556
+ * 第一步:请求设备码
557
+ * GitHub App 不需要 scope 参数(权限由 App 设置决定),OAuth App 可传 scope。
558
+ * 注意:Device Flow 端点在 github.com(而非 api.github.com),需确保网络可达。
559
+ * @returns device_code, user_code, verification_uri, expires_in, interval
560
+ */
561
+ static async deviceFlowRequestCode(clientId: string, host?: string): Promise<{
562
+ device_code: string;
563
+ user_code: string;
564
+ verification_uri: string;
565
+ expires_in: number;
566
+ interval: number;
567
+ }> {
568
+ const baseUrl = host ? `https://${host}` : 'https://github.com';
569
+ const url = `${baseUrl}/login/device/code`;
570
+ let res: Response;
571
+ try {
572
+ res = await fetch(url, {
573
+ method: 'POST',
574
+ headers: {
575
+ 'Content-Type': 'application/json',
576
+ 'Accept': 'application/json',
577
+ },
578
+ body: JSON.stringify({ client_id: clientId }),
579
+ signal: AbortSignal.timeout(15_000),
580
+ });
581
+ } catch (e: any) {
582
+ throw new Error(
583
+ `Device Flow 请求失败: 无法连接 ${baseUrl} (${e.cause?.code || e.message})。` +
584
+ `Device Flow 需要访问 github.com(非 api.github.com),请检查网络/代理设置。`,
585
+ );
586
+ }
587
+ if (!res.ok) {
588
+ const body = await res.text().catch(() => '');
589
+ const hint = res.status === 400
590
+ ? ' (请确认 GitHub App 已启用 Device Flow: Settings → Developer settings → GitHub Apps → General)'
591
+ : '';
592
+ throw new Error(`Device Flow 请求失败: ${res.status}${body ? ' — ' + body : ''}${hint}`);
593
+ }
594
+ return res.json();
595
+ }
596
+
597
+ /**
598
+ * 第二步:轮询等待用户授权
599
+ * @returns access_token 或 null(超时/拒绝)
600
+ */
601
+ static async deviceFlowPollToken(
602
+ clientId: string,
603
+ deviceCode: string,
604
+ interval: number = 5,
605
+ expiresIn: number = 900,
606
+ host?: string,
607
+ ): Promise<{ access_token: string; token_type: string; scope: string } | null> {
608
+ const baseUrl = host ? `https://${host}` : 'https://github.com';
609
+ const deadline = Date.now() + expiresIn * 1000;
610
+ let pollInterval = interval * 1000;
611
+
612
+ while (Date.now() < deadline) {
613
+ await new Promise(r => setTimeout(r, pollInterval));
614
+ const res = await fetch(`${baseUrl}/login/oauth/access_token`, {
615
+ method: 'POST',
616
+ headers: {
617
+ 'Content-Type': 'application/json',
618
+ 'Accept': 'application/json',
619
+ },
620
+ body: JSON.stringify({
621
+ client_id: clientId,
622
+ device_code: deviceCode,
623
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
624
+ }),
625
+ });
626
+ const data = await res.json() as Record<string, any>;
627
+ if (data.access_token) {
628
+ return { access_token: data.access_token, token_type: data.token_type, scope: data.scope };
629
+ }
630
+ if (data.error === 'slow_down') {
631
+ pollInterval = (data.interval || interval + 5) * 1000;
632
+ continue;
633
+ }
634
+ if (data.error === 'authorization_pending') continue;
635
+ // expired_token / access_denied / 其他错误
636
+ return null;
637
+ }
638
+ return null;
639
+ }
640
+ }