@zhin.js/adapter-github 1.0.1 → 1.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +1118 -0
  2. package/README.md +70 -191
  3. package/adapters/github.js +51 -0
  4. package/adapters/github.ts +59 -0
  5. package/agent/prompt-sections/platform.ts +16 -0
  6. package/{skills/github/SKILL.md → agent/skills/github.md} +22 -5
  7. package/agent/tools/bind.ts +13 -0
  8. package/agent/tools/create_pr.ts +20 -0
  9. package/agent/tools/install.ts +13 -0
  10. package/agent/tools/patch_file.ts +19 -0
  11. package/agent/tools/prepare_workspace.ts +15 -0
  12. package/agent/tools/push_branch.ts +18 -0
  13. package/agent/tools/star.ts +16 -0
  14. package/agent/tools/subscribe.ts +16 -0
  15. package/agent/tools/subscriptions.ts +13 -0
  16. package/agent/tools/unbind.ts +13 -0
  17. package/agent/tools/unsubscribe.ts +15 -0
  18. package/agent/tools/whoami.ts +13 -0
  19. package/commands/endpoint/add/[id].js +3 -0
  20. package/commands/endpoint/add/[id].ts +3 -0
  21. package/commands/endpoint/list.js +3 -0
  22. package/commands/endpoint/list.ts +3 -0
  23. package/commands/endpoint/remove/[id].js +3 -0
  24. package/commands/endpoint/remove/[id].ts +3 -0
  25. package/lib/client.d.ts +36 -0
  26. package/lib/client.js +47 -0
  27. package/lib/endpoint.d.ts +32 -22
  28. package/lib/endpoint.js +125 -145
  29. package/lib/gh-client.d.ts +73 -1
  30. package/lib/gh-client.js +99 -1
  31. package/lib/github-bot-handlers.d.ts +27 -0
  32. package/lib/github-bot-handlers.js +76 -0
  33. package/lib/github-channel-context.d.ts +16 -0
  34. package/lib/github-channel-context.js +31 -0
  35. package/lib/github-endpoint-commands.d.ts +1 -0
  36. package/lib/github-endpoint-commands.js +22 -0
  37. package/lib/github-runtime-state.d.ts +1 -0
  38. package/lib/github-runtime-state.js +6 -0
  39. package/lib/github-tool-handlers.d.ts +18 -0
  40. package/lib/github-tool-handlers.js +214 -0
  41. package/lib/index.d.ts +7 -32
  42. package/lib/index.js +7 -385
  43. package/lib/oauth-users.d.ts +33 -0
  44. package/lib/oauth-users.js +38 -0
  45. package/lib/protocol.d.ts +94 -0
  46. package/lib/protocol.js +292 -0
  47. package/lib/types.d.ts +6 -1
  48. package/lib/types.js +0 -1
  49. package/lib/webhook.d.ts +14 -0
  50. package/lib/webhook.js +88 -0
  51. package/lib/workspace-manager.d.ts +21 -0
  52. package/lib/workspace-manager.js +154 -0
  53. package/package.json +77 -23
  54. package/plugin.js +38 -0
  55. package/schema.json +130 -0
  56. package/src/client.ts +65 -0
  57. package/src/endpoint.ts +145 -150
  58. package/src/gh-client.ts +131 -0
  59. package/src/github-bot-handlers.ts +113 -0
  60. package/src/github-channel-context.ts +46 -0
  61. package/src/github-endpoint-commands.ts +23 -0
  62. package/src/github-runtime-state.ts +7 -0
  63. package/src/github-tool-handlers.ts +252 -0
  64. package/src/index.ts +48 -431
  65. package/src/oauth-users.ts +47 -0
  66. package/src/protocol.ts +425 -0
  67. package/src/types.ts +6 -0
  68. package/src/webhook.ts +130 -0
  69. package/src/workspace-manager.ts +168 -0
  70. package/lib/adapter.d.ts +0 -64
  71. package/lib/adapter.d.ts.map +0 -1
  72. package/lib/adapter.js +0 -416
  73. package/lib/adapter.js.map +0 -1
  74. package/lib/agent-prompt.d.ts +0 -3
  75. package/lib/agent-prompt.d.ts.map +0 -1
  76. package/lib/agent-prompt.js +0 -82
  77. package/lib/agent-prompt.js.map +0 -1
  78. package/lib/endpoint.d.ts.map +0 -1
  79. package/lib/endpoint.js.map +0 -1
  80. package/lib/gh-client.d.ts.map +0 -1
  81. package/lib/gh-client.js.map +0 -1
  82. package/lib/index.d.ts.map +0 -1
  83. package/lib/index.js.map +0 -1
  84. package/lib/register-github-mcp.d.ts +0 -6
  85. package/lib/register-github-mcp.d.ts.map +0 -1
  86. package/lib/register-github-mcp.js +0 -35
  87. package/lib/register-github-mcp.js.map +0 -1
  88. package/lib/types.d.ts.map +0 -1
  89. package/lib/types.js.map +0 -1
  90. package/plugin.yml +0 -3
  91. package/src/adapter.ts +0 -448
  92. package/src/agent-prompt.ts +0 -99
  93. package/src/register-github-mcp.ts +0 -60
package/src/endpoint.ts CHANGED
@@ -1,174 +1,169 @@
1
+ import { Endpoint } from 'zhin.js/adapter';
1
2
  /**
2
- * GitHub Endpoint 实现(基于 gh CLI)
3
+ * GithubEndpoint lifecycle, outbound send, inbound admit.
3
4
  */
4
- import { formatCompact, Endpoint, Message, segment, SendContent, SendOptions, type MessageSegment } from 'zhin.js';
5
- import type {
6
- GitHubEndpointConfig,
7
- IssueCommentPayload,
8
- PRReviewCommentPayload,
9
- PRReviewPayload,
10
- } from './types.js';
11
- import { buildChannelId, parseChannelId } from './types.js';
12
- import type { GitHubAdapter } from './adapter.js';
5
+ import { type EndpointSendRequest } from 'zhin.js/adapter';
6
+ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
7
+ import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
8
+ import type { CapabilityId, PluginDatabaseHost } from 'zhin.js';
13
9
  import { GhClient } from './gh-client.js';
14
-
15
- export function parseMarkdown(md: string): MessageSegment[] {
16
- const segments: MessageSegment[] = [];
17
- const mentionRe = /@(\w[-\w]*)/g;
18
- let lastIdx = 0;
19
- let match: RegExpExecArray | null;
20
- while ((match = mentionRe.exec(md)) !== null) {
21
- if (match.index > lastIdx) segments.push({ type: 'text', data: { text: md.slice(lastIdx, match.index) } });
22
- segments.push({ type: 'at', data: { id: match[1], name: match[1], text: match[0] } });
23
- lastIdx = match.index + match[0].length;
24
- }
25
- if (lastIdx < md.length) segments.push({ type: 'text', data: { text: md.slice(lastIdx) } });
26
- return segments.length ? segments : [{ type: 'text', data: { text: md } }];
10
+ import {
11
+ enrichInboundContent,
12
+ formatInboundContent,
13
+ formatOutboundBody,
14
+ githubInboundConversation,
15
+ parseChannelId,
16
+ type GithubInboundComment,
17
+ type ResolvedGithubConfig,
18
+ } from './protocol.js';
19
+ import { registerGithubWebhookRoutes } from './webhook.js';
20
+ import { GithubClient } from './client.js';
21
+
22
+ export interface GithubEndpointOptions {
23
+ readonly id: CapabilityId;
24
+ readonly http?: HttpHost;
25
+ readonly database?: PluginDatabaseHost;
26
+ readonly config: ResolvedGithubConfig;
27
+ readonly createClient?: (config: ResolvedGithubConfig) => GhClient;
27
28
  }
28
29
 
29
- export function toMarkdown(content: SendContent): string {
30
- if (!Array.isArray(content)) content = [content];
31
- return content.map(seg => {
32
- if (typeof seg === 'string') return seg;
33
- switch (seg.type) {
34
- case 'text': return seg.data.text || '';
35
- case 'at': return `@${seg.data.name || seg.data.id}`;
36
- case 'image': return seg.data.url ? `![image](${seg.data.url})` : '[image]';
37
- case 'link': return `[${seg.data.text || seg.data.url}](${seg.data.url})`;
38
- default: return seg.data?.text || `[${seg.type}]`;
39
- }
40
- }).join('');
41
- }
42
-
43
- export class GitHubEndpoint implements Endpoint<GitHubEndpointConfig, IssueCommentPayload> {
44
- $connected = false;
45
- gh: GhClient;
46
-
47
- get $id() { return this.$config.name; }
48
-
49
- get logger() {
50
- return this.adapter.plugin.logger;
51
- }
52
-
53
- constructor(public adapter: GitHubAdapter, public $config: GitHubEndpointConfig) {
54
- const { host, app_id, private_key } = $config;
55
- const appAuth = app_id && private_key
56
- ? { appId: app_id, privateKey: private_key }
57
- : undefined;
58
- this.gh = new GhClient({ host, appAuth });
30
+ /**
31
+ * GitHub 是代码协作面(issue/PR),无好友/群/频道等 IM 社交概念,
32
+ * 不适用 EndpointManagement 语义端口;本 endpoint 不暴露该端口。
33
+ */
34
+ export class GithubEndpoint extends Endpoint<GithubClient> {
35
+ readonly #logger!: ReturnType<typeof getAdapterLogger>;
36
+
37
+ readonly #options: GithubEndpointOptions;
38
+ readonly client: GithubClient;
39
+ #routeReleases: HttpRouteRegistration[] = [];
40
+ #open = false;
41
+ #started = false;
42
+
43
+ constructor(options: GithubEndpointOptions) {
44
+ super();
45
+ this.#logger = getAdapterLogger('github', options.config.id);
46
+ this.#options = options;
47
+ const api = options.createClient?.(options.config) ?? defaultCreateClient(options.config);
48
+ this.client = new GithubClient(options.config.id, api, options.config, options.database);
59
49
  }
60
50
 
61
- async $connect(): Promise<void> {
62
- const result = await this.gh.verifyAuth();
63
- if (!result.ok) throw new Error(`GitHub 认证失败: ${result.message}`);
64
- this.$connected = true;
65
- this.logger.info(formatCompact({ endpoint: this.$id }));
51
+ get config(): ResolvedGithubConfig {
52
+ return this.client.config;
66
53
  }
67
54
 
68
- async $disconnect(): Promise<void> {
69
- this.$connected = false;
70
- this.logger.debug(formatCompact({ endpoint: this.$id, disconnect: true }));
55
+ async start(): Promise<void> {
56
+ if (this.#started) return;
57
+ this.#started = true;
58
+ try {
59
+ const result = await this.client.api.verifyAuth();
60
+ if (!result.ok) throw new Error(`GitHub 认证失败: ${result.message}`);
61
+ if (this.client.config.webhookSecret) {
62
+ if (!this.#options.http) {
63
+ throw new TypeError('GitHub webhook_secret requires httpHostToken');
64
+ }
65
+ this.#routeReleases.push(...registerGithubWebhookRoutes(this.#options.http, this));
66
+ this.#logger.debug(formatCompact({
67
+ endpoint: this.client.name,
68
+ op: 'webhook',
69
+ path: this.client.config.webhookPath,
70
+ }));
71
+ } else {
72
+ this.#logger.debug(formatCompact({
73
+ endpoint: this.client.name,
74
+ op: 'connect',
75
+ mode: 'api-only',
76
+ bot: this.client.api.authenticatedUser,
77
+ }));
78
+ }
79
+ } catch (error) {
80
+ await this.stop();
81
+ this.#logger.error('Failed to connect GitHub endpoint:', error);
82
+ throw error;
83
+ }
71
84
  }
72
85
 
73
- $formatMessage(payload: IssueCommentPayload): Message<IssueCommentPayload> {
74
- const repo = payload.repository.full_name;
75
- const number = payload.issue.number;
76
- const isPR = 'pull_request' in (payload.issue as any);
77
- const channelId = buildChannelId(repo, isPR ? 'pr' : 'issue', number);
78
- const gh = this.gh;
79
-
80
- const result = Message.from(payload, {
81
- $id: payload.comment.id.toString(),
82
- $adapter: 'github',
83
- $endpoint: this.$config.name,
84
- $sender: { id: payload.sender.login, name: payload.sender.login },
85
- $channel: { id: channelId, type: 'group' },
86
- $content: parseMarkdown(payload.comment.body),
87
- $raw: payload.comment.body,
88
- $timestamp: new Date(payload.comment.created_at).getTime(),
89
- $recall: async () => { await gh.deleteIssueComment(repo, payload.comment.id); },
90
- $reply: async (content: SendContent, quote?: boolean | string): Promise<string> => {
91
- const text = toMarkdown(content);
92
- const finalBody = quote ? `> ${payload.comment.body.split('\n')[0]}\n\n${text}` : text;
93
- const r = await gh.createIssueComment(repo, number, finalBody);
94
- return r.ok ? r.data.id.toString() : '';
95
- },
96
- });
97
- return result;
86
+ open(): void {
87
+ this.#open = true;
98
88
  }
99
89
 
100
- formatPRReviewComment(payload: PRReviewCommentPayload): Message<PRReviewCommentPayload> {
101
- const repo = payload.repository.full_name;
102
- const number = payload.pull_request.number;
103
- const channelId = buildChannelId(repo, 'pr', number);
104
- const gh = this.gh;
105
-
106
- const body = payload.comment.path
107
- ? `**${payload.comment.path}**\n${payload.comment.diff_hunk ? '```diff\n' + payload.comment.diff_hunk + '\n```\n' : ''}${payload.comment.body}`
108
- : payload.comment.body;
109
-
110
- return Message.from(payload, {
111
- $id: payload.comment.id.toString(),
112
- $adapter: 'github',
113
- $endpoint: this.$config.name,
114
- $sender: { id: payload.sender.login, name: payload.sender.login },
115
- $channel: { id: channelId, type: 'group' },
116
- $content: parseMarkdown(body),
117
- $raw: body,
118
- $timestamp: new Date(payload.comment.created_at).getTime(),
119
- $recall: async () => { await gh.deletePRReviewComment(repo, payload.comment.id); },
120
- $reply: async (content: SendContent): Promise<string> => {
121
- const r = await gh.createPRComment(repo, number, toMarkdown(content));
122
- return r.ok ? r.data.id.toString() : '';
123
- },
124
- });
90
+ close(): void {
91
+ this.#open = false;
125
92
  }
126
93
 
127
- formatPRReview(payload: PRReviewPayload): Message<PRReviewPayload> | null {
128
- if (!payload.review.body) return null;
129
- const repo = payload.repository.full_name;
130
- const number = payload.pull_request.number;
131
- const channelId = buildChannelId(repo, 'pr', number);
132
- const gh = this.gh;
133
-
134
- const stateLabel: Record<string, string> = {
135
- approved: '✅ APPROVED', changes_requested: '🔄 CHANGES REQUESTED',
136
- commented: '💬 COMMENTED', dismissed: '❌ DISMISSED',
137
- };
138
- const body = `**[${stateLabel[payload.review.state] || payload.review.state}]**\n${payload.review.body}`;
139
-
140
- return Message.from(payload, {
141
- $id: payload.review.id.toString(),
142
- $adapter: 'github',
143
- $endpoint: this.$config.name,
144
- $sender: { id: payload.sender.login, name: payload.sender.login },
145
- $channel: { id: channelId, type: 'group' },
146
- $content: parseMarkdown(body),
147
- $raw: body,
148
- $timestamp: new Date(payload.review.submitted_at).getTime(),
149
- $recall: async () => {},
150
- $reply: async (content: SendContent): Promise<string> => {
151
- const r = await gh.createPRComment(repo, number, toMarkdown(content));
152
- return r.ok ? r.data.id.toString() : '';
153
- },
154
- });
94
+ async stop(): Promise<void> {
95
+ this.#open = false;
96
+ for (const release of this.#routeReleases.splice(0)) release();
97
+ this.#started = false;
98
+ this.#logger.debug(formatCompact({ op: 'disconnect' }));
155
99
  }
156
100
 
157
- async $sendMessage(options: SendOptions): Promise<string> {
158
- const parsed = parseChannelId(options.id);
159
- if (!parsed) throw new Error(`无效的 GitHub channel ID: ${options.id}`);
160
-
161
- const text = toMarkdown(options.content);
101
+ async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
102
+ const parsed = parseChannelId(conversation.id);
103
+ if (!parsed) throw new Error(`无效的 GitHub conversation ID: ${conversation.id}`);
104
+ const text = formatOutboundBody(payload);
162
105
  const r = parsed.type === 'issue'
163
- ? await this.gh.createIssueComment(parsed.repo, parsed.number, text)
164
- : await this.gh.createPRComment(parsed.repo, parsed.number, text);
106
+ ? await this.client.api.createIssueComment(parsed.repo, parsed.number, text)
107
+ : await this.client.api.createPRComment(parsed.repo, parsed.number, text);
165
108
  if (!r.ok) throw new Error(`发送失败: ${JSON.stringify(r.data)}`);
109
+ this.#logger.debug(formatCompact({
110
+ op: 'github_send',
111
+ endpoint: this.client.name,
112
+ target: `${conversation.kind}:${conversation.id}`,
113
+ messageId: r.data.id,
114
+ }));
115
+ return String(r.data.id);
116
+ }
166
117
 
167
- this.logger.debug(`${this.$id} send ${options.id}: ${text.slice(0, 80)}...`);
168
- return r.data.id.toString();
118
+ /** Test / internal: admit a parsed comment when open. */
119
+ admit(comment: GithubInboundComment): void {
120
+ if (!this.#open) return;
121
+ const botUser = this.client.config.botLogin
122
+ || this.client.api.getBotLogin()
123
+ || this.client.api.authenticatedUser;
124
+ if (botUser && comment.sender === botUser) return;
125
+ const conversation = githubInboundConversation(String(this.#options.id), comment);
126
+ const content = enrichInboundContent(
127
+ formatInboundContent(comment.content),
128
+ this.client.config,
129
+ botUser ?? undefined,
130
+ comment.repo,
131
+ );
132
+ void this.emit('message.receive', {
133
+ conversation,
134
+ message: { conversation, id: comment.id },
135
+ content,
136
+ sender: { id: comment.sender, name: comment.sender },
137
+ endpointId: this.client.name,
138
+ metadata: Object.freeze({
139
+ repo: comment.repo,
140
+ kind: comment.kind,
141
+ createdAt: comment.createdAt,
142
+ }),
143
+ }).catch((err) => {
144
+ this.#logger.warn(formatCompact({
145
+ op: 'github_gateway_receive_failed',
146
+ target: `${conversation.kind}:${conversation.id}`,
147
+ error: err instanceof Error ? err.message : String(err),
148
+ }));
149
+ });
169
150
  }
170
151
 
171
- async $recallMessage(id: string): Promise<void> {
172
- this.logger.warn(formatCompact( { op: 'recall', ok: false, error: 'use message.$recall()' }));
152
+ admitPlatform(name: string, event: unknown): void {
153
+ if (!this.#open) return;
154
+ void this.emitPlatform(name, event).catch((error) => {
155
+ this.#logger.warn(formatCompact({
156
+ op: 'github_platform_event_failed',
157
+ event: name,
158
+ error: error instanceof Error ? error.message : String(error),
159
+ }));
160
+ });
173
161
  }
174
162
  }
163
+
164
+ export function defaultCreateClient(config: ResolvedGithubConfig): GhClient {
165
+ const appAuth = config.appId && config.privateKey
166
+ ? { appId: config.appId, privateKey: config.privateKey }
167
+ : undefined;
168
+ return new GhClient({ host: config.host, appAuth });
169
+ }
package/src/gh-client.ts CHANGED
@@ -21,6 +21,19 @@ export interface AppAuth {
21
21
  privateKey: string;
22
22
  }
23
23
 
24
+ export interface GitHubBotIdentity {
25
+ login: string;
26
+ email: string;
27
+ slug: string;
28
+ userId: number;
29
+ }
30
+
31
+ export interface GitHubCommitAuthor {
32
+ name: string;
33
+ email: string;
34
+ date?: string;
35
+ }
36
+
24
37
  export interface GhClientOptions {
25
38
  /** GitHub Enterprise 主机名(默认 github.com) */
26
39
  host?: string;
@@ -201,6 +214,7 @@ export class GhClient {
201
214
  this._user = name;
202
215
  this._appSlug = appData.slug || null;
203
216
  if (appData.client_id) this._clientId = appData.client_id;
217
+ await this.resolveBotUserId();
204
218
  const repos = this._repoToInstallation.size;
205
219
  return { ok: true, user: name, message: `GitHub App: ${name} (${this._allInstallations.length} 安装, ${repos} 仓库)` };
206
220
  }
@@ -313,6 +327,114 @@ export class GhClient {
313
327
  return this.del(`/repos/${repo}/pulls/comments/${commentId}`);
314
328
  }
315
329
 
330
+ // ── Contents / Pulls(Bot 写操作)──────────────────────────────────
331
+
332
+ async getFileContent(repo: string, path: string, ref?: string) {
333
+ let apiPath = `/repos/${repo}/contents/${path.split('/').map(encodeURIComponent).join('/')}`;
334
+ if (ref) apiPath += `?ref=${encodeURIComponent(ref)}`;
335
+ return this.get<{ content: string; sha: string; encoding: string }>(apiPath);
336
+ }
337
+
338
+ async createOrUpdateFile(
339
+ repo: string,
340
+ path: string,
341
+ content: string,
342
+ message: string,
343
+ options?: { branch?: string; sha?: string },
344
+ ) {
345
+ const body: Record<string, unknown> = {
346
+ message,
347
+ content: Buffer.from(content, 'utf-8').toString('base64'),
348
+ };
349
+ if (options?.branch) body.branch = options.branch;
350
+ if (options?.sha) body.sha = options.sha;
351
+ const apiPath = `/repos/${repo}/contents/${path.split('/').map(encodeURIComponent).join('/')}`;
352
+ return this.put<{ commit: { sha: string; html_url?: string }; content: { html_url?: string } }>(
353
+ apiPath,
354
+ body,
355
+ );
356
+ }
357
+
358
+ async getRef(repo: string, ref: string) {
359
+ return this.get<{ ref: string; object: { sha: string; type: string } }>(
360
+ `/repos/${repo}/git/ref/${encodeURIComponent(ref)}`,
361
+ );
362
+ }
363
+
364
+ async createRef(repo: string, ref: string, sha: string) {
365
+ return this.post(`/repos/${repo}/git/refs`, { ref, sha });
366
+ }
367
+
368
+ async createPullRequest(
369
+ repo: string,
370
+ title: string,
371
+ head: string,
372
+ base: string,
373
+ body?: string,
374
+ ) {
375
+ return this.post<{ number: number; html_url: string; head: { ref: string } }>(
376
+ `/repos/${repo}/pulls`,
377
+ { title, head, base, body: body ?? '' },
378
+ );
379
+ }
380
+
381
+ getBotLogin(): string | null {
382
+ if (this._user) return this._user;
383
+ if (this._appSlug) return `${this._appSlug}[bot]`;
384
+ return null;
385
+ }
386
+
387
+ getCommitAuthor(): GitHubCommitAuthor | null {
388
+ const identity = this.getBotIdentitySync();
389
+ if (!identity) return null;
390
+ return { name: identity.login, email: identity.email, date: new Date().toISOString() };
391
+ }
392
+
393
+ getBotIdentitySync(): GitHubBotIdentity | null {
394
+ const slug = this._appSlug;
395
+ const login = this.getBotLogin();
396
+ if (!slug || !login || this._botUserId == null) return null;
397
+ return {
398
+ login,
399
+ slug,
400
+ userId: this._botUserId,
401
+ email: `${this._botUserId}+${login}@users.noreply.github.com`,
402
+ };
403
+ }
404
+
405
+ async getBotIdentity(): Promise<GitHubBotIdentity | null> {
406
+ if (!this._appAuth) return null;
407
+ if (this._botUserId == null) await this.resolveBotUserId();
408
+ return this.getBotIdentitySync();
409
+ }
410
+
411
+ buildCloneUrl(repo: string, token: string): string {
412
+ const host = this.host || 'github.com';
413
+ return `https://x-access-token:${encodeURIComponent(token)}@${host}/${repo}.git`;
414
+ }
415
+
416
+ async resolveBotUserId(): Promise<number | null> {
417
+ if (this._botUserId != null) return this._botUserId;
418
+ const slug = this._appSlug;
419
+ if (!slug) return null;
420
+ const login = `${slug}[bot]`;
421
+ const baseUrl = this.host ? `https://${this.host}/api/v3` : 'https://api.github.com';
422
+ try {
423
+ const res = await fetch(`${baseUrl}/users/${encodeURIComponent(login)}`, {
424
+ headers: { Accept: 'application/vnd.github+json' },
425
+ });
426
+ if (!res.ok) return null;
427
+ const data = (await res.json()) as { id?: number };
428
+ if (typeof data.id === 'number') {
429
+ this._botUserId = data.id;
430
+ return data.id;
431
+ }
432
+ } catch {
433
+ return null;
434
+ }
435
+ return null;
436
+ }
437
+
316
438
  // ── Star ─────────────────────────────────────────────────────────
317
439
 
318
440
  async starRepo(repo: string) {
@@ -445,6 +567,15 @@ export class GhClient {
445
567
 
446
568
  /** App 的 client_id(verifyAuth 后从 /app 获取,用于 Device Flow) */
447
569
  private _clientId: string | null = null;
570
+ /** Bot 机器用户 numeric id(/users/{slug}[bot]) */
571
+ private _botUserId: number | null = null;
572
+
573
+ /** 确保 App 认证的 Installation Token 对指定 repo 有效,并返回 token */
574
+ async ensureInstallationTokenForRepo(repo: string): Promise<string | undefined> {
575
+ if (!this._appAuth) return this.token;
576
+ await this.ensureTokenForRepo(repo);
577
+ return this.token;
578
+ }
448
579
  get clientId(): string | null { return this._clientId; }
449
580
 
450
581
  // ── 事件轮询 ─────────────────────────────────────────────────────
@@ -0,0 +1,113 @@
1
+ /**
2
+ * GitHub App Bot workflow handlers (Installation Token identity).
3
+ */
4
+ import type { Message } from 'zhin.js';
5
+ import { getCurrentCommMessage } from '@zhin.js/agent/security';
6
+ import type { GithubClient } from './client.js';
7
+ import {
8
+ parseMessageChannel,
9
+ resolveWorkspaceBranch,
10
+ formatChannelContext,
11
+ } from './github-channel-context.js';
12
+
13
+ function requireBotGh(client: GithubClient) {
14
+ const gh = client.api;
15
+ if (!gh?.isAppAuth) {
16
+ throw new Error('需要 GitHub App 认证(app_id + private_key),Bot 写操作不可用');
17
+ }
18
+ return gh;
19
+ }
20
+
21
+ function resolveCommMessage(commMessage?: Message): Message {
22
+ const msg = commMessage ?? getCurrentCommMessage();
23
+ if (!msg) throw new Error('无 IM 上下文,请在 Issue/PR 评论线程内调用');
24
+ return msg;
25
+ }
26
+
27
+ function resolveChannel(msg: Message, repo?: string) {
28
+ const ctx = parseMessageChannel(msg);
29
+ if (ctx) return ctx;
30
+ if (repo) {
31
+ throw new Error(`请从 GitHub Issue/PR 频道调用,或提供完整 channel(当前仅 repo=${repo})`);
32
+ }
33
+ throw new Error('无法解析 GitHub 频道(需要 Issue/PR 评论上下文)');
34
+ }
35
+
36
+ export async function executeGithubPrepareWorkspace(
37
+ args: { repo?: string },
38
+ client: GithubClient,
39
+ commMessage?: Message,
40
+ ) {
41
+ const msg = resolveCommMessage(commMessage);
42
+ const ctx = resolveChannel(msg, args.repo);
43
+ const gh = requireBotGh(client);
44
+ const wm = client.workspaceManager;
45
+ const { branch, base } = await resolveWorkspaceBranch(gh, ctx);
46
+ const repoPath = await wm.checkoutBranch(ctx.repo, branch, base);
47
+ return [
48
+ `✅ 工作区就绪`,
49
+ `📁 ${repoPath}`,
50
+ `🌿 分支 \`${branch}\`(base: \`${base}\`)`,
51
+ `📍 ${formatChannelContext(ctx)}`,
52
+ ].join('\n');
53
+ }
54
+
55
+ export async function executeGithubPatchFile(
56
+ args: { repo?: string; path: string; content: string; message: string; branch?: string },
57
+ client: GithubClient,
58
+ commMessage?: Message,
59
+ ) {
60
+ const msg = resolveCommMessage(commMessage);
61
+ const ctx = resolveChannel(msg, args.repo);
62
+ const gh = requireBotGh(client);
63
+ const { branch } = args.branch
64
+ ? { branch: args.branch }
65
+ : await resolveWorkspaceBranch(gh, ctx);
66
+
67
+ const existing = await gh.getFileContent(ctx.repo, args.path, branch);
68
+ const sha = existing.ok ? existing.data.sha : undefined;
69
+ const r = await gh.createOrUpdateFile(
70
+ ctx.repo,
71
+ args.path,
72
+ args.content,
73
+ args.message,
74
+ { branch, sha },
75
+ );
76
+ if (!r.ok) {
77
+ return `❌ 更新文件失败: ${JSON.stringify(r.data)}`;
78
+ }
79
+ const url = r.data.content?.html_url ?? r.data.commit?.html_url ?? '';
80
+ return `✅ 已更新 \`${args.path}\` @ \`${branch}\`${url ? `\n🔗 ${url}` : ''}`;
81
+ }
82
+
83
+ export async function executeGithubPushBranch(
84
+ args: { repo?: string; branch?: string; message: string },
85
+ client: GithubClient,
86
+ commMessage?: Message,
87
+ ) {
88
+ const msg = resolveCommMessage(commMessage);
89
+ const ctx = resolveChannel(msg, args.repo);
90
+ const gh = requireBotGh(client);
91
+ const wm = client.workspaceManager;
92
+ const branch = args.branch ?? (await resolveWorkspaceBranch(gh, ctx)).branch;
93
+ const result = await wm.commitAndPush(ctx.repo, branch, args.message);
94
+ return `✅ ${result}\n📍 ${ctx.repo} @ \`${branch}\``;
95
+ }
96
+
97
+ export async function executeGithubCreatePr(
98
+ args: { repo?: string; title: string; body?: string; head?: string; base?: string },
99
+ client: GithubClient,
100
+ commMessage?: Message,
101
+ ) {
102
+ const msg = resolveCommMessage(commMessage);
103
+ const ctx = resolveChannel(msg, args.repo);
104
+ const gh = requireBotGh(client);
105
+ const resolved = await resolveWorkspaceBranch(gh, ctx);
106
+ const head = args.head ?? resolved.branch;
107
+ const base = args.base ?? resolved.base;
108
+ const r = await gh.createPullRequest(ctx.repo, args.title, head, base, args.body);
109
+ if (!r.ok) {
110
+ return `❌ 创建 PR 失败: ${JSON.stringify(r.data)}`;
111
+ }
112
+ return `✅ PR #${r.data.number} 已创建\n🔗 ${r.data.html_url}`;
113
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Parse GitHub Issue/PR channel context from IM messages.
3
+ */
4
+ import type { Message } from 'zhin.js';
5
+ import { buildChannelId, parseChannelId, type ParsedChannel } from './types.js';
6
+ import type { GhClient } from './gh-client.js';
7
+
8
+ export interface GitHubChannelContext extends ParsedChannel {
9
+ channelId: string;
10
+ }
11
+
12
+ export function parseMessageChannel(message: Message): GitHubChannelContext | null {
13
+ const channelId = message.$channel?.id;
14
+ if (!channelId) return null;
15
+ const parsed = parseChannelId(channelId);
16
+ if (!parsed) return null;
17
+ return { ...parsed, channelId };
18
+ }
19
+
20
+ export function issueBranchName(number: number): string {
21
+ return `zhin/bot/issue-${number}`;
22
+ }
23
+
24
+ export async function resolveWorkspaceBranch(
25
+ gh: GhClient,
26
+ ctx: GitHubChannelContext,
27
+ ): Promise<{ branch: string; base: string }> {
28
+ if (ctx.type === 'pr') {
29
+ const pr = await gh.getPR(ctx.repo, ctx.number);
30
+ if (pr.ok && pr.data?.head?.ref) {
31
+ const base = pr.data.base?.ref ?? 'main';
32
+ return { branch: pr.data.head.ref, base };
33
+ }
34
+ throw new Error(`无法读取 PR #${ctx.number} 的 head 分支`);
35
+ }
36
+
37
+ const repoInfo = await gh.getRepo(ctx.repo);
38
+ const base = repoInfo.ok && repoInfo.data?.default_branch
39
+ ? String(repoInfo.data.default_branch)
40
+ : 'main';
41
+ return { branch: issueBranchName(ctx.number), base };
42
+ }
43
+
44
+ export function formatChannelContext(ctx: GitHubChannelContext): string {
45
+ return buildChannelId(ctx.repo, ctx.type, ctx.number);
46
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `github.endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
3
+ * commands/endpoint/ 下的 list / add / remove 直接默认导出这三项。
4
+ *
5
+ * 字段说明:private_key 内容长且含换行,不适合 kv 直传——运行时(gh-client
6
+ * resolvePrivateKey)本就支持 PEM 内容或文件路径,故 add 采用**内联路径**形式:
7
+ * `github.endpoint add mybot app_id=123456 private_key=./data/mybot.pem`。
8
+ */
9
+ import { createEndpointCommands } from 'zhin.js/adapter';
10
+ import { defineCommand } from 'zhin.js/command';
11
+ import { githubRuntimeStateToken } from './github-runtime-state.js';
12
+
13
+ export const githubEndpointCommands = createEndpointCommands({
14
+ adapterKey: 'github',
15
+ adapterDisplayName: 'GitHub',
16
+ fields: [
17
+ { key: 'app_id', required: true, env: true, description: 'GitHub App ID' },
18
+ { key: 'private_key', required: true, description: 'PEM 文件路径(推荐,如 ./data/xxx.pem)或 PEM 内容' },
19
+ { key: 'webhook_secret', env: true, description: 'webhook 签名密钥(不配则 API-only)' },
20
+ ],
21
+ running: (use) => use(githubRuntimeStateToken).endpoints.values(),
22
+ describeEntry: (entry) => `app_id: ${String(entry.app_id ?? entry.appId)}`,
23
+ }, defineCommand);
@@ -0,0 +1,7 @@
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');