@zhin.js/adapter-github 1.1.18 → 1.1.20

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/src/gh-client.ts CHANGED
@@ -10,6 +10,8 @@
10
10
  */
11
11
 
12
12
  import { spawn } from 'node:child_process';
13
+ import { createEndpointOperation } from 'zhin.js/adapter';
14
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
13
15
  import crypto from 'node:crypto';
14
16
  import fs from 'node:fs';
15
17
 
@@ -35,6 +37,7 @@ export interface GitHubCommitAuthor {
35
37
  }
36
38
 
37
39
  export interface GhClientOptions {
40
+ readonly signal?: AbortSignal;
38
41
  /** GitHub Enterprise 主机名(默认 github.com) */
39
42
  host?: string;
40
43
  /** 覆盖 gh CLI 默认认证的 Personal Access Token / OAuth Token */
@@ -43,8 +46,27 @@ export interface GhClientOptions {
43
46
  appAuth?: AppAuth;
44
47
  }
45
48
 
49
+ /** Per-request cancellation is independent of this account's execution lifetime. */
50
+ export interface GhRequestLifetime { readonly signal?: AbortSignal }
51
+ /** An API action and its transport inputs, retained together through authentication and subprocess IO. */
52
+ export interface GhApiRequest extends GhRequestLifetime {
53
+ readonly method: string;
54
+ readonly path: string;
55
+ readonly body?: unknown;
56
+ readonly headers?: Readonly<Record<string, string>>;
57
+ }
58
+ /** One comment publication, shared by issue and PR conversation sends. */
59
+ export interface GhCommentRequest extends GhRequestLifetime {
60
+ readonly repo: string;
61
+ readonly number: number;
62
+ readonly body: string;
63
+ }
64
+ interface GhCommandRequest extends GhRequestLifetime { readonly args: readonly string[]; readonly stdin?: string; readonly token?: string }
65
+
46
66
  export class GhClient {
47
- private host?: string;
67
+ protected apiHost?: string;
68
+ protected executionSignal?: AbortSignal;
69
+ protected bindExecutionSignal(signal: AbortSignal): void { this.executionSignal = signal; }
48
70
  private token?: string;
49
71
  private _appAuth?: AppAuth;
50
72
  private _resolvedKey?: string;
@@ -59,27 +81,34 @@ export class GhClient {
59
81
  private _lastSyncTime = 0;
60
82
 
61
83
  constructor(options: GhClientOptions = {}) {
62
- this.host = options.host;
84
+ this.apiHost = options.host;
85
+ this.executionSignal = options.signal;
63
86
  this.token = options.token;
64
87
  this._appAuth = options.appAuth;
65
88
  }
66
89
 
90
+ protected fetchNative(input: string, init?: RequestInit): Promise<Response> {
91
+ this.executionSignal?.throwIfAborted(); init?.signal?.throwIfAborted();
92
+ return fetch(input, { ...init, ...(this.executionSignal ? { signal: init?.signal ? AbortSignal.any([this.executionSignal, init.signal]) : this.executionSignal } : {}) });
93
+ }
94
+
67
95
  /** 创建使用指定 token 的副本(共享 host 配置,不继承 App 认证) */
68
96
  withToken(token: string): GhClient {
69
- return new GhClient({ host: this.host, token });
97
+ return new GhClient({ host: this.apiHost, token, signal: this.executionSignal });
70
98
  }
71
99
 
72
100
  get authenticatedUser() { return this._user; }
73
101
 
74
102
  // ── 底层执行 ──────────────────────────────────────────────────────
75
103
 
76
- private exec(args: string[], stdin?: string): Promise<string> {
77
- const fullArgs = this.host ? [...args, '--hostname', this.host] : args;
78
- const env = this.token
79
- ? { ...process.env, GH_TOKEN: this.token }
104
+ private exec({ args, stdin, token, signal = this.executionSignal }: GhCommandRequest): Promise<string> {
105
+ signal?.throwIfAborted();
106
+ const fullArgs = this.apiHost ? [...args, '--hostname', this.apiHost] : args;
107
+ const env = token
108
+ ? { ...process.env, GH_TOKEN: token }
80
109
  : undefined;
81
110
  return new Promise((resolve, reject) => {
82
- const proc = spawn('gh', fullArgs, { stdio: ['pipe', 'pipe', 'pipe'], env });
111
+ const proc = spawn('gh', fullArgs, { stdio: ['pipe', 'pipe', 'pipe'], env, signal });
83
112
  let stdout = '';
84
113
  let stderr = '';
85
114
  proc.stdout.on('data', (d: Buffer) => (stdout += d.toString()));
@@ -110,8 +139,8 @@ export class GhClient {
110
139
  * 确保 App 认证的 Installation Token 有效(自动按 repo 查找对应安装)
111
140
  * @param repo 仓库全名(owner/repo),用于选择正确的 Installation
112
141
  */
113
- private async ensureTokenForRepo(repo?: string): Promise<void> {
114
- if (!this._appAuth) return;
142
+ private async ensureTokenForRepo(repo?: string, signal?: AbortSignal): Promise<string | undefined> {
143
+ if (!this._appAuth) return this.token;
115
144
  let installId: number | undefined;
116
145
  if (repo) {
117
146
  const key = repo.toLowerCase();
@@ -128,7 +157,7 @@ export class GhClient {
128
157
  }
129
158
  // 懒加载重新发现(5 分钟冷却)
130
159
  if (!installId && Date.now() - this._lastSyncTime > 5 * 60_000) {
131
- await this.syncInstallations();
160
+ await this.syncInstallations({ signal });
132
161
  installId = this._repoToInstallation.get(key);
133
162
  if (!installId) {
134
163
  const owner = key.split('/')[0];
@@ -145,24 +174,21 @@ export class GhClient {
145
174
  if (!installId && this._allInstallations.length) {
146
175
  installId = this._allInstallations[0].id;
147
176
  }
148
- if (!installId) return;
177
+ if (!installId) return this.token;
149
178
  const cached = this._installationTokens.get(installId);
150
179
  if (cached && cached.expiresAt > Date.now() + 60_000) {
151
- this.token = cached.token;
152
- return;
180
+ return cached.token;
153
181
  }
154
- await this.refreshInstallationToken(installId);
182
+ await this.refreshInstallationToken(installId, { signal });
183
+ return this._installationTokens.get(installId)?.token;
155
184
  }
156
185
 
157
- async request<T = any>(
158
- method: string,
159
- path: string,
160
- body?: any,
161
- headers?: Record<string, string>,
162
- ): Promise<{ ok: boolean; status: number; data: T }> {
186
+ async request<T = any>({ method, path, body, headers, signal }: GhApiRequest): Promise<{ ok: boolean; status: number; data: T }> {
187
+ const operationSignal = this.executionSignal && signal ? AbortSignal.any([this.executionSignal, signal]) : this.executionSignal ?? signal;
188
+ const operation = createEndpointOperation({ signal: operationSignal });
163
189
  // 从路径自动检测 repo 以选择正确的 Installation Token
164
190
  const repoMatch = path.match(/^\/repos\/([^/?]+\/[^/?]+)/);
165
- await this.ensureTokenForRepo(repoMatch?.[1]);
191
+ const token = await operation.prepare(() => this.ensureTokenForRepo(repoMatch?.[1], operationSignal));
166
192
  const args = ['api', path, '--method', method];
167
193
  if (body) args.push('--input', '-');
168
194
  if (headers) {
@@ -171,11 +197,13 @@ export class GhClient {
171
197
  }
172
198
  }
173
199
  try {
174
- const raw = await this.exec(args, body ? JSON.stringify(body) : undefined);
200
+ const execute = () => this.exec({ args, stdin: body ? JSON.stringify(body) : undefined, token, signal: operationSignal });
201
+ const raw = await operation.run(execute);
175
202
  let data: any;
176
203
  try { data = raw ? JSON.parse(raw) : null; } catch { data = raw; }
177
204
  return { ok: true, status: 200, data };
178
205
  } catch (err: unknown) {
206
+ if (err instanceof EndpointDeliveryError) throw err;
179
207
  let data: any;
180
208
  const errObj = err instanceof Error ? err : new Error(String(err));
181
209
  try { data = (err as any).stdout ? JSON.parse((err as any).stdout) : { message: errObj.message }; } catch {
@@ -185,11 +213,11 @@ export class GhClient {
185
213
  }
186
214
  }
187
215
 
188
- private get<T = any>(path: string) { return this.request<T>('GET', path); }
189
- private post<T = any>(path: string, body?: any) { return this.request<T>('POST', path, body); }
190
- private patch<T = any>(path: string, body?: any) { return this.request<T>('PATCH', path, body); }
191
- private put<T = any>(path: string, body?: any) { return this.request<T>('PUT', path, body); }
192
- private del<T = any>(path: string, body?: any) { return this.request<T>('DELETE', path, body); }
216
+ private get<T = any>(path: string) { return this.request<T>({ method: 'GET', path }); }
217
+ private post<T = any>(path: string, body?: any) { return this.request<T>({ method: 'POST', path, body }); }
218
+ private patch<T = any>(path: string, body?: any) { return this.request<T>({ method: 'PATCH', path, body }); }
219
+ private put<T = any>(path: string, body?: any) { return this.request<T>({ method: 'PUT', path, body }); }
220
+ private del<T = any>(path: string, body?: any) { return this.request<T>({ method: 'DELETE', path, body }); }
193
221
 
194
222
  // ── 认证验证 ──────────────────────────────────────────────────────
195
223
 
@@ -204,8 +232,8 @@ export class GhClient {
204
232
  // 用 JWT 查询 App 信息(/app 端点只接受 JWT 认证,不能用 Installation Token)
205
233
  const key = this.resolvePrivateKey();
206
234
  const jwt = GhClient.createJWT(this._appAuth.appId, key);
207
- const baseUrl = this.host ? `https://${this.host}/api/v3` : 'https://api.github.com';
208
- const appRes = await fetch(`${baseUrl}/app`, {
235
+ const baseUrl = this.apiHost ? `https://${this.apiHost}/api/v3` : 'https://api.github.com';
236
+ const appRes = await this.fetchNative(`${baseUrl}/app`, {
209
237
  headers: { Authorization: `Bearer ${jwt}`, Accept: 'application/vnd.github+json' },
210
238
  });
211
239
  if (appRes.ok) {
@@ -235,8 +263,8 @@ export class GhClient {
235
263
  }
236
264
  return { ok: false, user: '', message: 'Token 无效' };
237
265
  }
238
- await this.exec(['auth', 'status']);
239
- const raw = await this.exec(['api', '/user', '--jq', '.login']);
266
+ await this.exec({ args: ['auth', 'status'], token: this.token });
267
+ const raw = await this.exec({ args: ['api', '/user', '--jq', '.login'], token: this.token });
240
268
  const login = raw.trim();
241
269
  this._user = login;
242
270
  return { ok: true, user: login, message: `gh CLI: ${login}` };
@@ -308,19 +336,18 @@ export class GhClient {
308
336
  return this.patch(`/repos/${repo}/pulls/${number}`, { state: 'closed' });
309
337
  }
310
338
 
311
- async createIssueComment(repo: string, issueNumber: number, body: string) {
312
- return this.post<{ id: number; html_url: string; message?: string }>(
313
- `/repos/${repo}/issues/${issueNumber}/comments`,
314
- { body },
315
- );
339
+ async createIssueComment({ repo, number, body, signal }: GhCommentRequest) {
340
+ return this.request<{ id: number; html_url: string; message?: string }>({
341
+ method: 'POST', path: `/repos/${repo}/issues/${number}/comments`, body: { body }, signal,
342
+ });
316
343
  }
317
344
 
318
345
  async deleteIssueComment(repo: string, commentId: number) {
319
346
  return this.del(`/repos/${repo}/issues/comments/${commentId}`);
320
347
  }
321
348
 
322
- async createPRComment(repo: string, prNumber: number, body: string) {
323
- return this.createIssueComment(repo, prNumber, body);
349
+ async createPRComment(request: GhCommentRequest) {
350
+ return this.createIssueComment(request);
324
351
  }
325
352
 
326
353
  async deletePRReviewComment(repo: string, commentId: number) {
@@ -409,7 +436,7 @@ export class GhClient {
409
436
  }
410
437
 
411
438
  buildCloneUrl(repo: string, token: string): string {
412
- const host = this.host || 'github.com';
439
+ const host = this.apiHost || 'github.com';
413
440
  return `https://x-access-token:${encodeURIComponent(token)}@${host}/${repo}.git`;
414
441
  }
415
442
 
@@ -418,9 +445,9 @@ export class GhClient {
418
445
  const slug = this._appSlug;
419
446
  if (!slug) return null;
420
447
  const login = `${slug}[bot]`;
421
- const baseUrl = this.host ? `https://${this.host}/api/v3` : 'https://api.github.com';
448
+ const baseUrl = this.apiHost ? `https://${this.apiHost}/api/v3` : 'https://api.github.com';
422
449
  try {
423
- const res = await fetch(`${baseUrl}/users/${encodeURIComponent(login)}`, {
450
+ const res = await this.fetchNative(`${baseUrl}/users/${encodeURIComponent(login)}`, {
424
451
  headers: { Accept: 'application/vnd.github+json' },
425
452
  });
426
453
  if (!res.ok) return null;
@@ -446,7 +473,7 @@ export class GhClient {
446
473
  }
447
474
 
448
475
  async isStarred(repo: string): Promise<boolean> {
449
- const r = await this.request('GET', `/user/starred/${repo}`);
476
+ const r = await this.request({ method: 'GET', path: `/user/starred/${repo}` });
450
477
  return r.ok;
451
478
  }
452
479
 
@@ -487,17 +514,18 @@ export class GhClient {
487
514
  }
488
515
 
489
516
  /** 使用 JWT 获取指定 Installation 的 Access Token(有效期 1 小时) */
490
- async refreshInstallationToken(installationId: number): Promise<void> {
517
+ async refreshInstallationToken(installationId: number, { signal }: GhRequestLifetime = {}): Promise<void> {
518
+ signal?.throwIfAborted();
491
519
  if (!this._appAuth) throw new Error('未配置 App 认证');
492
520
  const key = this.resolvePrivateKey();
493
521
  const jwt = GhClient.createJWT(this._appAuth.appId, key);
494
- const baseUrl = this.host
495
- ? `https://${this.host}/api/v3`
522
+ const baseUrl = this.apiHost
523
+ ? `https://${this.apiHost}/api/v3`
496
524
  : 'https://api.github.com';
497
- const res = await fetch(
525
+ const res = await this.fetchNative(
498
526
  `${baseUrl}/app/installations/${installationId}/access_tokens`,
499
527
  {
500
- method: 'POST',
528
+ method: 'POST', signal,
501
529
  headers: {
502
530
  Authorization: `Bearer ${jwt}`,
503
531
  Accept: 'application/vnd.github+json',
@@ -509,6 +537,7 @@ export class GhClient {
509
537
  throw new Error(`Installation Token 获取失败 (${res.status}): ${body}`);
510
538
  }
511
539
  const data = (await res.json()) as { token: string; expires_at: string };
540
+ signal?.throwIfAborted();
512
541
  this.token = data.token;
513
542
  this._installationTokens.set(installationId, {
514
543
  token: data.token,
@@ -517,13 +546,14 @@ export class GhClient {
517
546
  }
518
547
 
519
548
  /** 发现所有 App 安装及其可访问的仓库 */
520
- async syncInstallations(): Promise<void> {
549
+ async syncInstallations({ signal }: GhRequestLifetime = {}): Promise<void> {
550
+ signal?.throwIfAborted();
521
551
  if (!this._appAuth) return;
522
552
  const key = this.resolvePrivateKey();
523
553
  const jwt = GhClient.createJWT(this._appAuth.appId, key);
524
- const baseUrl = this.host ? `https://${this.host}/api/v3` : 'https://api.github.com';
525
- const listRes = await fetch(`${baseUrl}/app/installations`, {
526
- headers: { Authorization: `Bearer ${jwt}`, Accept: 'application/vnd.github+json' },
554
+ const baseUrl = this.apiHost ? `https://${this.apiHost}/api/v3` : 'https://api.github.com';
555
+ const listRes = await this.fetchNative(`${baseUrl}/app/installations`, {
556
+ headers: { Authorization: `Bearer ${jwt}`, Accept: 'application/vnd.github+json' }, signal,
527
557
  });
528
558
  if (!listRes.ok) {
529
559
  const errBody = await listRes.text();
@@ -532,23 +562,26 @@ export class GhClient {
532
562
  const installations = (await listRes.json()) as Array<{
533
563
  id: number; account: { login: string; type: string }; target_type: string;
534
564
  }>;
565
+ signal?.throwIfAborted();
535
566
  this._allInstallations = installations;
536
567
  this._repoToInstallation.clear();
537
568
  for (const inst of installations) {
538
569
  try {
539
- await this.refreshInstallationToken(inst.id);
570
+ await this.refreshInstallationToken(inst.id, { signal });
540
571
  const cached = this._installationTokens.get(inst.id);
541
572
  if (!cached) continue;
542
- const repoRes = await fetch(`${baseUrl}/installation/repositories?per_page=100`, {
543
- headers: { Authorization: `Bearer ${cached.token}`, Accept: 'application/vnd.github+json' },
573
+ const repoRes = await this.fetchNative(`${baseUrl}/installation/repositories?per_page=100`, {
574
+ headers: { Authorization: `Bearer ${cached.token}`, Accept: 'application/vnd.github+json' }, signal,
544
575
  });
545
576
  if (repoRes.ok) {
546
577
  const data = (await repoRes.json()) as { repositories: Array<{ full_name: string }> };
578
+ signal?.throwIfAborted();
547
579
  for (const repo of data.repositories) {
548
580
  this._repoToInstallation.set(repo.full_name.toLowerCase(), inst.id);
549
581
  }
550
582
  }
551
583
  } catch {
584
+ signal?.throwIfAborted(); this.executionSignal?.throwIfAborted();
552
585
  // 单个安装失败不影响其他
553
586
  }
554
587
  }
@@ -588,11 +621,11 @@ export class GhClient {
588
621
  repo: string,
589
622
  etag?: string,
590
623
  ): Promise<{ events: any[]; etag: string | null }> {
591
- await this.ensureTokenForRepo(repo);
624
+ const token = await this.ensureTokenForRepo(repo);
592
625
  const args = ['api', `/repos/${repo}/events?per_page=30`, '--method', 'GET', '--include'];
593
626
  if (etag) args.push('-H', `If-None-Match: ${etag}`);
594
627
  try {
595
- const raw = await this.exec(args);
628
+ const raw = await this.exec({ args, token });
596
629
  // --include 输出包含 HTTP 头 + 空行 + body
597
630
  const sepIdx = raw.indexOf('\r\n\r\n');
598
631
  const headerPart = sepIdx >= 0 ? raw.slice(0, sepIdx) : '';
package/src/index.ts CHANGED
@@ -21,7 +21,7 @@ export {
21
21
  export * from './types.js';
22
22
 
23
23
  export { GhClient } from './gh-client.js';
24
- export { GithubClient, githubClient, type GithubClientEventMap } from './client.js';
24
+ export type { GithubClientEventMap } from './client.js';
25
25
  export { WorkspaceManager } from './workspace-manager.js';
26
26
  export {
27
27
  parseMessageChannel,
@@ -49,3 +49,4 @@ export {
49
49
  dispatchGithubWebhookPayload,
50
50
  type GithubWebhookHandler,
51
51
  } from './webhook.js';
52
+ export { GithubAdapter } from './adapter.js';
package/src/webhook.ts CHANGED
@@ -23,7 +23,7 @@ const logger = getLogger('github');
23
23
  export interface GithubWebhookHandler {
24
24
  readonly config: ResolvedGithubConfig;
25
25
  admitPlatform(name: string, event: unknown): void;
26
- admit(comment: GithubInboundComment): void;
26
+ admit(comment: GithubInboundComment, nativeEvent?: unknown): void;
27
27
  }
28
28
 
29
29
  export function registerGithubWebhookRoutes(
@@ -114,17 +114,17 @@ export async function dispatchGithubWebhookPayload(
114
114
 
115
115
  if (event === 'issue_comment') {
116
116
  const inbound = parseIssueCommentInbound(payload as IssueCommentPayload);
117
- if (inbound) handler.admit(inbound);
117
+ if (inbound) handler.admit(inbound, payload);
118
118
  return;
119
119
  }
120
120
  if (event === 'pull_request_review_comment') {
121
121
  const inbound = parsePrReviewCommentInbound(payload as PRReviewCommentPayload);
122
- if (inbound) handler.admit(inbound);
122
+ if (inbound) handler.admit(inbound, payload);
123
123
  return;
124
124
  }
125
125
  if (event === 'pull_request_review') {
126
126
  const inbound = parsePrReviewInbound(payload as PRReviewPayload);
127
- if (inbound) handler.admit(inbound);
127
+ if (inbound) handler.admit(inbound, payload);
128
128
  }
129
129
  // Cross-adapter subscription fan-out deferred (needs multi-adapter send in Runtime).
130
130
  }
@@ -1 +0,0 @@
1
- export declare const githubRuntimeStateToken: import("zhin.js").Token<import("@zhin.js/adapter").EndpointRuntimeState>;
@@ -1,6 +0,0 @@
1
- /**
2
- * GitHub 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
3
- * 由 plugin.ts setup() provide,adapter create 与 `github endpoint` 命令共享(同一 owner generation)。
4
- */
5
- import { defineEndpointRuntimeStateToken } from 'zhin.js/adapter';
6
- export const githubRuntimeStateToken = defineEndpointRuntimeStateToken('github');
@@ -1,7 +0,0 @@
1
- /**
2
- * GitHub 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
3
- * 由 plugin.ts setup() provide,adapter create 与 `github endpoint` 命令共享(同一 owner generation)。
4
- */
5
- import { defineEndpointRuntimeStateToken } from 'zhin.js/adapter';
6
-
7
- export const githubRuntimeStateToken = defineEndpointRuntimeStateToken('github');