@zhin.js/adapter-github 1.0.0 → 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.
- package/CHANGELOG.md +1118 -0
- package/README.md +70 -191
- package/adapters/github.js +51 -0
- package/adapters/github.ts +59 -0
- package/agent/prompt-sections/platform.ts +16 -0
- package/{skills/github/SKILL.md → agent/skills/github.md} +26 -9
- package/agent/tools/bind.ts +13 -0
- package/agent/tools/create_pr.ts +20 -0
- package/agent/tools/install.ts +13 -0
- package/agent/tools/patch_file.ts +19 -0
- package/agent/tools/prepare_workspace.ts +15 -0
- package/agent/tools/push_branch.ts +18 -0
- package/agent/tools/star.ts +16 -0
- package/agent/tools/subscribe.ts +16 -0
- package/agent/tools/subscriptions.ts +13 -0
- package/agent/tools/unbind.ts +13 -0
- package/agent/tools/unsubscribe.ts +15 -0
- package/agent/tools/whoami.ts +13 -0
- package/commands/endpoint/add/[id].js +3 -0
- package/commands/endpoint/add/[id].ts +3 -0
- package/commands/endpoint/list.js +3 -0
- package/commands/endpoint/list.ts +3 -0
- package/commands/endpoint/remove/[id].js +3 -0
- package/commands/endpoint/remove/[id].ts +3 -0
- package/lib/client.d.ts +36 -0
- package/lib/client.js +47 -0
- package/lib/endpoint.d.ts +36 -0
- package/lib/endpoint.js +140 -0
- package/lib/gh-client.d.ts +73 -1
- package/lib/gh-client.js +109 -7
- package/lib/github-bot-handlers.d.ts +27 -0
- package/lib/github-bot-handlers.js +76 -0
- package/lib/github-channel-context.d.ts +16 -0
- package/lib/github-channel-context.js +31 -0
- package/lib/github-endpoint-commands.d.ts +1 -0
- package/lib/github-endpoint-commands.js +22 -0
- package/lib/github-runtime-state.d.ts +1 -0
- package/lib/github-runtime-state.js +6 -0
- package/lib/github-tool-handlers.d.ts +18 -0
- package/lib/github-tool-handlers.js +214 -0
- package/lib/index.d.ts +7 -32
- package/lib/index.js +7 -385
- package/lib/oauth-users.d.ts +33 -0
- package/lib/oauth-users.js +38 -0
- package/lib/protocol.d.ts +94 -0
- package/lib/protocol.js +292 -0
- package/lib/types.d.ts +9 -4
- package/lib/types.js +1 -2
- package/lib/webhook.d.ts +14 -0
- package/lib/webhook.js +88 -0
- package/lib/workspace-manager.d.ts +21 -0
- package/lib/workspace-manager.js +154 -0
- package/package.json +77 -22
- package/plugin.js +38 -0
- package/schema.json +130 -0
- package/src/client.ts +65 -0
- package/src/endpoint.ts +169 -0
- package/src/gh-client.ts +147 -12
- package/src/github-bot-handlers.ts +113 -0
- package/src/github-channel-context.ts +46 -0
- package/src/github-endpoint-commands.ts +23 -0
- package/src/github-runtime-state.ts +7 -0
- package/src/github-tool-handlers.ts +252 -0
- package/src/index.ts +48 -431
- package/src/oauth-users.ts +47 -0
- package/src/protocol.ts +425 -0
- package/src/types.ts +10 -4
- package/src/webhook.ts +130 -0
- package/src/workspace-manager.ts +168 -0
- package/lib/adapter.d.ts +0 -63
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -415
- package/lib/adapter.js.map +0 -1
- package/lib/agent-prompt.d.ts +0 -3
- package/lib/agent-prompt.d.ts.map +0 -1
- package/lib/agent-prompt.js +0 -82
- package/lib/agent-prompt.js.map +0 -1
- package/lib/bot.d.ts +0 -26
- package/lib/bot.d.ts.map +0 -1
- package/lib/bot.js +0 -160
- package/lib/bot.js.map +0 -1
- package/lib/gh-client.d.ts.map +0 -1
- package/lib/gh-client.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/register-github-mcp.d.ts +0 -6
- package/lib/register-github-mcp.d.ts.map +0 -1
- package/lib/register-github-mcp.js +0 -35
- package/lib/register-github-mcp.js.map +0 -1
- package/lib/types.d.ts.map +0 -1
- package/lib/types.js.map +0 -1
- package/plugin.yml +0 -3
- package/src/adapter.ts +0 -446
- package/src/agent-prompt.ts +0 -99
- package/src/bot.ts +0 -174
- package/src/register-github-mcp.ts +0 -60
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Managed git workspaces for GitHub App Bot development flow.
|
|
3
|
+
*/
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import type { GhClient, GitHubBotIdentity } from './gh-client.js';
|
|
8
|
+
|
|
9
|
+
const REPO_FULL_NAME_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
10
|
+
const GIT_REF_ILLEGAL_CHARS = new Set('~^:?*[\\`;');
|
|
11
|
+
|
|
12
|
+
function containsIllegalGitRefCharacter(ref: string): boolean {
|
|
13
|
+
return [...ref].some((character) => {
|
|
14
|
+
const code = character.charCodeAt(0);
|
|
15
|
+
return code <= 31
|
|
16
|
+
|| code === 127
|
|
17
|
+
|| /\s/u.test(character)
|
|
18
|
+
|| GIT_REF_ILLEGAL_CHARS.has(character);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 校验 GitHub "owner/name" 全名,防止路径穿越与 git 选项注入。
|
|
24
|
+
* 返回通过校验的原值,便于调用方直接使用「已消毒」的变量。
|
|
25
|
+
*/
|
|
26
|
+
export function assertRepoFullName(repo: string): string {
|
|
27
|
+
if (
|
|
28
|
+
typeof repo !== 'string' ||
|
|
29
|
+
!REPO_FULL_NAME_RE.test(repo) ||
|
|
30
|
+
repo.split('/').some((seg) => seg === '' || seg.startsWith('-') || /^\.+$/.test(seg))
|
|
31
|
+
) {
|
|
32
|
+
throw new TypeError(`非法的仓库全名: ${JSON.stringify(repo)}`);
|
|
33
|
+
}
|
|
34
|
+
return repo;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 校验 git ref 名称,拒绝选项注入(`-` 前缀)与 git 非法字符。
|
|
39
|
+
*/
|
|
40
|
+
export function assertGitRefName(ref: string): string {
|
|
41
|
+
if (typeof ref !== 'string' || ref.length === 0) {
|
|
42
|
+
throw new TypeError(`非法的 git ref: ${JSON.stringify(ref)}`);
|
|
43
|
+
}
|
|
44
|
+
if (
|
|
45
|
+
ref.startsWith('-') ||
|
|
46
|
+
ref.includes('..') ||
|
|
47
|
+
containsIllegalGitRefCharacter(ref) ||
|
|
48
|
+
ref.endsWith('/') ||
|
|
49
|
+
ref.endsWith('.')
|
|
50
|
+
) {
|
|
51
|
+
throw new TypeError(`非法的 git ref: ${JSON.stringify(ref)}`);
|
|
52
|
+
}
|
|
53
|
+
return ref;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class WorkspaceManager {
|
|
57
|
+
constructor(
|
|
58
|
+
private readonly gh: GhClient,
|
|
59
|
+
private readonly rootDir: string,
|
|
60
|
+
) {}
|
|
61
|
+
|
|
62
|
+
getRepoPath(repo: string): string {
|
|
63
|
+
repo = assertRepoFullName(repo);
|
|
64
|
+
const [owner, name] = repo.split('/');
|
|
65
|
+
return path.join(this.rootDir, owner, name);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private runGit(cwd: string, args: string[], env?: NodeJS.ProcessEnv): Promise<string> {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const proc = spawn('git', args, {
|
|
71
|
+
cwd,
|
|
72
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
73
|
+
env: { ...process.env, ...env },
|
|
74
|
+
});
|
|
75
|
+
let stdout = '';
|
|
76
|
+
let stderr = '';
|
|
77
|
+
proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
|
|
78
|
+
proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
|
|
79
|
+
proc.on('error', (err: NodeJS.ErrnoException) => {
|
|
80
|
+
if (err.code === 'ENOENT') reject(new Error('git 未安装'));
|
|
81
|
+
else reject(err);
|
|
82
|
+
});
|
|
83
|
+
proc.on('close', (code) => {
|
|
84
|
+
if (code === 0) resolve(stdout);
|
|
85
|
+
else reject(new Error(stderr.trim() || `git ${args.join(' ')} failed (${code})`));
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
configureBotGit(repoPath: string, identity: GitHubBotIdentity): Promise<void> {
|
|
91
|
+
return Promise.all([
|
|
92
|
+
this.runGit(repoPath, ['config', 'user.name', identity.login]),
|
|
93
|
+
this.runGit(repoPath, ['config', 'user.email', identity.email]),
|
|
94
|
+
]).then(() => undefined);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async ensureRepo(repo: string): Promise<string> {
|
|
98
|
+
repo = assertRepoFullName(repo);
|
|
99
|
+
if (!this.gh.isAppAuth) {
|
|
100
|
+
throw new Error('GitHub App 认证未配置,无法托管工作区');
|
|
101
|
+
}
|
|
102
|
+
const token = await this.gh.ensureInstallationTokenForRepo(repo);
|
|
103
|
+
if (!token) throw new Error(`无法获取 ${repo} 的 Installation Token`);
|
|
104
|
+
|
|
105
|
+
const repoPath = this.getRepoPath(repo);
|
|
106
|
+
fs.mkdirSync(path.dirname(repoPath), { recursive: true });
|
|
107
|
+
|
|
108
|
+
const identity = await this.gh.getBotIdentity();
|
|
109
|
+
if (!identity) throw new Error('无法解析 GitHub App Bot 身份');
|
|
110
|
+
|
|
111
|
+
const cloneUrl = this.gh.buildCloneUrl(repo, token);
|
|
112
|
+
if (!fs.existsSync(path.join(repoPath, '.git'))) {
|
|
113
|
+
await this.runGit(path.dirname(repoPath), ['clone', cloneUrl, path.basename(repoPath)]);
|
|
114
|
+
} else {
|
|
115
|
+
await this.runGit(repoPath, ['remote', 'set-url', 'origin', cloneUrl]);
|
|
116
|
+
await this.runGit(repoPath, ['fetch', 'origin', '--prune']);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
await this.configureBotGit(repoPath, identity);
|
|
120
|
+
return repoPath;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async checkoutBranch(repo: string, branch: string, baseRef: string): Promise<string> {
|
|
124
|
+
repo = assertRepoFullName(repo);
|
|
125
|
+
branch = assertGitRefName(branch);
|
|
126
|
+
baseRef = assertGitRefName(baseRef);
|
|
127
|
+
const repoPath = await this.ensureRepo(repo);
|
|
128
|
+
const localBranches = await this.runGit(repoPath, ['branch', '--list', branch]);
|
|
129
|
+
if (localBranches.trim()) {
|
|
130
|
+
await this.runGit(repoPath, ['checkout', branch]);
|
|
131
|
+
await this.runGit(repoPath, ['pull', '--rebase', 'origin', branch]).catch(async () => {
|
|
132
|
+
await this.runGit(repoPath, ['fetch', 'origin', branch]);
|
|
133
|
+
});
|
|
134
|
+
return repoPath;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const remoteBranch = await this.runGit(repoPath, ['ls-remote', '--heads', 'origin', branch]);
|
|
138
|
+
if (remoteBranch.trim()) {
|
|
139
|
+
await this.runGit(repoPath, ['checkout', '-B', branch, `origin/${branch}`]);
|
|
140
|
+
return repoPath;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await this.runGit(repoPath, ['fetch', 'origin', baseRef]);
|
|
144
|
+
await this.runGit(repoPath, ['checkout', '-B', branch, `origin/${baseRef}`]);
|
|
145
|
+
return repoPath;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async commitAndPush(repo: string, branch: string, message: string): Promise<string> {
|
|
149
|
+
repo = assertRepoFullName(repo);
|
|
150
|
+
branch = assertGitRefName(branch);
|
|
151
|
+
const repoPath = this.getRepoPath(repo);
|
|
152
|
+
if (!fs.existsSync(repoPath)) {
|
|
153
|
+
throw new Error(`工作区不存在: ${repoPath},请先 github_prepare_workspace`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const token = await this.gh.ensureInstallationTokenForRepo(repo);
|
|
157
|
+
if (!token) throw new Error('Installation Token 不可用');
|
|
158
|
+
await this.runGit(repoPath, ['remote', 'set-url', 'origin', this.gh.buildCloneUrl(repo, token)]);
|
|
159
|
+
|
|
160
|
+
const status = await this.runGit(repoPath, ['status', '--porcelain']);
|
|
161
|
+
if (!status.trim()) return '没有可提交的变更';
|
|
162
|
+
|
|
163
|
+
await this.runGit(repoPath, ['add', '-A']);
|
|
164
|
+
await this.runGit(repoPath, ['commit', '-m', message]);
|
|
165
|
+
await this.runGit(repoPath, ['push', '-u', 'origin', branch]);
|
|
166
|
+
return `已 push 到 origin/${branch}`;
|
|
167
|
+
}
|
|
168
|
+
}
|
package/lib/adapter.d.ts
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* GitHub 适配器(基于 gh CLI + App 认证 + Webhook/轮询混合)
|
|
3
|
-
*/
|
|
4
|
-
import { Adapter, Plugin } from 'zhin.js';
|
|
5
|
-
import { GitHubBot } from './bot.js';
|
|
6
|
-
import type { Router } from '@zhin.js/host-router';
|
|
7
|
-
import type { GitHubBotConfig, GenericWebhookPayload } from './types.js';
|
|
8
|
-
import type { GhClient } from './gh-client.js';
|
|
9
|
-
export declare class GitHubAdapter extends Adapter<GitHubBot> {
|
|
10
|
-
/** 轮询定时器 */
|
|
11
|
-
private _pollTimer;
|
|
12
|
-
/** 每个 repo 的 ETag 缓存 */
|
|
13
|
-
private _etags;
|
|
14
|
-
/** 每个 repo 最后处理的事件 ID(防重复) */
|
|
15
|
-
private _lastEventIds;
|
|
16
|
-
/** Webhook 是否已激活 */
|
|
17
|
-
private _webhookActive;
|
|
18
|
-
constructor(plugin: Plugin);
|
|
19
|
-
createBot(config: GitHubBotConfig): GitHubBot;
|
|
20
|
-
start(): Promise<void>;
|
|
21
|
-
stop(): Promise<void>;
|
|
22
|
-
/** 获取第一个可用 bot 的 GhClient (工具用) */
|
|
23
|
-
getAPI(): GhClient | null;
|
|
24
|
-
/** 获取指定用户绑定的 GhClient;未绑定则返回 null */
|
|
25
|
-
getUserAPI(platform: string, platformUid: string): Promise<GhClient | null>;
|
|
26
|
-
/** 获取用户 API,若未绑定则降级为 bot 默认的 API */
|
|
27
|
-
getUserOrDefaultAPI(platform?: string, platformUid?: string): Promise<GhClient | null>;
|
|
28
|
-
/** 获取第一个 bot 的 client_id(App 认证时从 /app 自动获取) */
|
|
29
|
-
getClientId(): string | null;
|
|
30
|
-
/** 获取第一个 bot 的 host 配置 */
|
|
31
|
-
getHost(): string | undefined;
|
|
32
|
-
/** 获取 App slug(用于生成安装链接) */
|
|
33
|
-
getAppSlug(): string | null;
|
|
34
|
-
/** 获取所有已发现的安装信息 */
|
|
35
|
-
getInstallations(): {
|
|
36
|
-
id: number;
|
|
37
|
-
account: {
|
|
38
|
-
login: string;
|
|
39
|
-
type: string;
|
|
40
|
-
};
|
|
41
|
-
target_type: string;
|
|
42
|
-
}[];
|
|
43
|
-
/** 第一个 bot 是否配置了 Webhook */
|
|
44
|
-
get hasWebhookConfig(): boolean;
|
|
45
|
-
/** Webhook 是否已激活 */
|
|
46
|
-
get webhookActive(): boolean;
|
|
47
|
-
/** 在 router 上挂载 Webhook 路由(生产环境推荐) */
|
|
48
|
-
setupWebhook(router: Router): void;
|
|
49
|
-
/** 处理 Webhook 推送的事件 */
|
|
50
|
-
handleWebhookPayload(event: string, payload: any): Promise<void>;
|
|
51
|
-
/** 启动事件轮询 */
|
|
52
|
-
startPolling(): void;
|
|
53
|
-
/** 停止事件轮询 */
|
|
54
|
-
stopPolling(): void;
|
|
55
|
-
/** 轮询所有已订阅仓库的事件 */
|
|
56
|
-
private pollAllRepos;
|
|
57
|
-
/** 轮询单个仓库的事件 */
|
|
58
|
-
private pollRepoEvents;
|
|
59
|
-
/** Events API type → webhook event name */
|
|
60
|
-
private mapEventType;
|
|
61
|
-
dispatchNotification(eventName: string, payload: GenericWebhookPayload): Promise<void>;
|
|
62
|
-
}
|
|
63
|
-
//# sourceMappingURL=adapter.d.ts.map
|
package/lib/adapter.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,EAAiB,OAAO,EAAW,MAAM,EAAE,MAAM,SAAS,CAAC;AAElE,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AACrC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AACnD,OAAO,KAAK,EAAE,eAAe,EAAa,qBAAqB,EAAgB,MAAM,YAAY,CAAC;AAClG,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AA+D/C,qBAAa,aAAc,SAAQ,OAAO,CAAC,SAAS,CAAC;IACnD,YAAY;IACZ,OAAO,CAAC,UAAU,CAA+C;IACjE,wBAAwB;IACxB,OAAO,CAAC,MAAM,CAA6B;IAC3C,8BAA8B;IAC9B,OAAO,CAAC,aAAa,CAA6B;IAClD,oBAAoB;IACpB,OAAO,CAAC,cAAc,CAAS;gBAEnB,MAAM,EAAE,MAAM;IAI1B,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,SAAS;IAIvC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAItB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAK3B,mCAAmC;IACnC,MAAM,IAAI,QAAQ,GAAG,IAAI;IAKzB,qCAAqC;IAC/B,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;IAWjF,oCAAoC;IAC9B,mBAAmB,CAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;IAQ5F,gDAAgD;IAChD,WAAW,IAAI,MAAM,GAAG,IAAI;IAK5B,0BAA0B;IAC1B,OAAO,IAAI,MAAM,GAAG,SAAS;IAK7B,4BAA4B;IAC5B,UAAU,IAAI,MAAM,GAAG,IAAI;IAK3B,mBAAmB;IACnB,gBAAgB;;;;;;;;IAKhB,4BAA4B;IAC5B,IAAI,gBAAgB,IAAI,OAAO,CAG9B;IAED,oBAAoB;IACpB,IAAI,aAAa,IAAI,OAAO,CAE3B;IAID,sCAAsC;IACtC,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IA4ClC,uBAAuB;IACjB,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC;IA4DtE,aAAa;IACb,YAAY,IAAI,IAAI;IAcpB,aAAa;IACb,WAAW,IAAI,IAAI;IAQnB,mBAAmB;YACL,YAAY;IAsB1B,gBAAgB;YACF,cAAc;IAiE5B,2CAA2C;IAC3C,OAAO,CAAC,YAAY;IAgBd,oBAAoB,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC;CA+C7F"}
|
package/lib/adapter.js
DELETED
|
@@ -1,415 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* GitHub 适配器(基于 gh CLI + App 认证 + Webhook/轮询混合)
|
|
3
|
-
*/
|
|
4
|
-
import { formatCompact, Adapter } from 'zhin.js';
|
|
5
|
-
import crypto from 'node:crypto';
|
|
6
|
-
import { GitHubBot } from './bot.js';
|
|
7
|
-
const VALID_EVENTS = ['push', 'issue', 'star', 'fork', 'unstar', 'pull_request'];
|
|
8
|
-
function safeParseEvents(raw) {
|
|
9
|
-
if (Array.isArray(raw))
|
|
10
|
-
return raw;
|
|
11
|
-
if (typeof raw === 'string') {
|
|
12
|
-
try {
|
|
13
|
-
const parsed = JSON.parse(raw);
|
|
14
|
-
if (Array.isArray(parsed))
|
|
15
|
-
return parsed;
|
|
16
|
-
}
|
|
17
|
-
catch { }
|
|
18
|
-
}
|
|
19
|
-
return [];
|
|
20
|
-
}
|
|
21
|
-
function formatNotification(event, p) {
|
|
22
|
-
const repo = p.repository.full_name;
|
|
23
|
-
const sender = p.sender.login;
|
|
24
|
-
const repoUrl = p.repository.html_url;
|
|
25
|
-
switch (event) {
|
|
26
|
-
case 'push': {
|
|
27
|
-
const branch = p.ref?.replace('refs/heads/', '') || '?';
|
|
28
|
-
const commits = p.commits || [];
|
|
29
|
-
const compareUrl = commits.length >= 2
|
|
30
|
-
? `${repoUrl}/compare/${commits[0].id.substring(0, 12)}...${commits[commits.length - 1].id.substring(0, 12)}`
|
|
31
|
-
: commits.length === 1 ? `${repoUrl}/commit/${commits[0].id}` : '';
|
|
32
|
-
let msg = `📦 ${repo}\n🌿 ${sender} pushed ${commits.length} commit(s) to \`${branch}\`\n`;
|
|
33
|
-
if (commits.length) {
|
|
34
|
-
msg += '\n';
|
|
35
|
-
msg += commits.slice(0, 5).map(c => ` • [\`${c.id.substring(0, 7)}\`](${repoUrl}/commit/${c.id}) ${c.message.split('\n')[0]}`).join('\n');
|
|
36
|
-
if (commits.length > 5)
|
|
37
|
-
msg += `\n ... +${commits.length - 5} more`;
|
|
38
|
-
}
|
|
39
|
-
if (compareUrl)
|
|
40
|
-
msg += `\n\n🔗 ${compareUrl}`;
|
|
41
|
-
return msg;
|
|
42
|
-
}
|
|
43
|
-
case 'issues': {
|
|
44
|
-
const i = p.issue;
|
|
45
|
-
const act = p.action === 'opened' ? '📝 opened' : p.action === 'closed' ? '✅ closed' : `🔄 ${p.action || 'updated'}`;
|
|
46
|
-
let msg = `🐛 ${repo}\n👤 ${sender} ${act} issue #${i.number}\n📌 ${i.title}`;
|
|
47
|
-
msg += `\n🔗 ${i.html_url}`;
|
|
48
|
-
return msg;
|
|
49
|
-
}
|
|
50
|
-
case 'star': {
|
|
51
|
-
const starred = p.action !== 'deleted';
|
|
52
|
-
return `${starred ? '⭐' : '💔'} ${repo}\n👤 ${sender} ${starred ? 'starred' : 'unstarred'}\n🔗 ${repoUrl}`;
|
|
53
|
-
}
|
|
54
|
-
case 'fork':
|
|
55
|
-
return `🍴 ${repo}\n👤 ${sender} forked → ${p.forkee.full_name}\n🔗 ${p.forkee.html_url}`;
|
|
56
|
-
case 'pull_request': {
|
|
57
|
-
const pr = p.pull_request;
|
|
58
|
-
const act = p.action === 'opened' ? '📝 opened'
|
|
59
|
-
: p.action === 'closed' ? (pr.state === 'closed' ? '❌ closed' : '✅ merged')
|
|
60
|
-
: `🔄 ${p.action || 'updated'}`;
|
|
61
|
-
let msg = `🔀 ${repo}\n👤 ${sender} ${act} PR #${pr.number}\n📌 ${pr.title}`;
|
|
62
|
-
msg += `\n🌿 ${pr.head.ref} → ${pr.base.ref}`;
|
|
63
|
-
msg += `\n🔗 ${pr.html_url}`;
|
|
64
|
-
return msg;
|
|
65
|
-
}
|
|
66
|
-
default:
|
|
67
|
-
return `📬 ${repo}\n📡 ${event}${p.action ? ` (${p.action})` : ''} by ${sender}\n🔗 ${repoUrl}`;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
export class GitHubAdapter extends Adapter {
|
|
71
|
-
/** 轮询定时器 */
|
|
72
|
-
_pollTimer = null;
|
|
73
|
-
/** 每个 repo 的 ETag 缓存 */
|
|
74
|
-
_etags = new Map();
|
|
75
|
-
/** 每个 repo 最后处理的事件 ID(防重复) */
|
|
76
|
-
_lastEventIds = new Map();
|
|
77
|
-
/** Webhook 是否已激活 */
|
|
78
|
-
_webhookActive = false;
|
|
79
|
-
constructor(plugin) {
|
|
80
|
-
super(plugin, 'github', []);
|
|
81
|
-
}
|
|
82
|
-
createBot(config) {
|
|
83
|
-
return new GitHubBot(this, config);
|
|
84
|
-
}
|
|
85
|
-
async start() {
|
|
86
|
-
await super.start();
|
|
87
|
-
}
|
|
88
|
-
async stop() {
|
|
89
|
-
this.stopPolling();
|
|
90
|
-
await super.stop();
|
|
91
|
-
}
|
|
92
|
-
/** 获取第一个可用 bot 的 GhClient (工具用) */
|
|
93
|
-
getAPI() {
|
|
94
|
-
const bot = this.bots.values().next().value;
|
|
95
|
-
return bot?.gh || null;
|
|
96
|
-
}
|
|
97
|
-
/** 获取指定用户绑定的 GhClient;未绑定则返回 null */
|
|
98
|
-
async getUserAPI(platform, platformUid) {
|
|
99
|
-
const db = this.plugin.root?.inject('database');
|
|
100
|
-
const model = db?.models?.get('github_oauth_users');
|
|
101
|
-
if (!model)
|
|
102
|
-
return null;
|
|
103
|
-
const [record] = await model.select().where({ platform, platform_uid: platformUid });
|
|
104
|
-
if (!record?.access_token)
|
|
105
|
-
return null;
|
|
106
|
-
const base = this.getAPI();
|
|
107
|
-
if (!base)
|
|
108
|
-
return null;
|
|
109
|
-
return base.withToken(record.access_token);
|
|
110
|
-
}
|
|
111
|
-
/** 获取用户 API,若未绑定则降级为 bot 默认的 API */
|
|
112
|
-
async getUserOrDefaultAPI(platform, platformUid) {
|
|
113
|
-
if (platform && platformUid) {
|
|
114
|
-
const userApi = await this.getUserAPI(platform, platformUid);
|
|
115
|
-
if (userApi)
|
|
116
|
-
return userApi;
|
|
117
|
-
}
|
|
118
|
-
return this.getAPI();
|
|
119
|
-
}
|
|
120
|
-
/** 获取第一个 bot 的 client_id(App 认证时从 /app 自动获取) */
|
|
121
|
-
getClientId() {
|
|
122
|
-
const bot = this.bots.values().next().value;
|
|
123
|
-
return bot?.gh.clientId || null;
|
|
124
|
-
}
|
|
125
|
-
/** 获取第一个 bot 的 host 配置 */
|
|
126
|
-
getHost() {
|
|
127
|
-
const bot = this.bots.values().next().value;
|
|
128
|
-
return bot?.$config.host;
|
|
129
|
-
}
|
|
130
|
-
/** 获取 App slug(用于生成安装链接) */
|
|
131
|
-
getAppSlug() {
|
|
132
|
-
const bot = this.bots.values().next().value;
|
|
133
|
-
return bot?.gh.appSlug || null;
|
|
134
|
-
}
|
|
135
|
-
/** 获取所有已发现的安装信息 */
|
|
136
|
-
getInstallations() {
|
|
137
|
-
const bot = this.bots.values().next().value;
|
|
138
|
-
return bot?.gh.installations || [];
|
|
139
|
-
}
|
|
140
|
-
/** 第一个 bot 是否配置了 Webhook */
|
|
141
|
-
get hasWebhookConfig() {
|
|
142
|
-
const bot = this.bots.values().next().value;
|
|
143
|
-
return !!bot?.$config.webhook_secret;
|
|
144
|
-
}
|
|
145
|
-
/** Webhook 是否已激活 */
|
|
146
|
-
get webhookActive() {
|
|
147
|
-
return this._webhookActive;
|
|
148
|
-
}
|
|
149
|
-
// ── Webhook ──────────────────────────────────────────────────────
|
|
150
|
-
/** 在 router 上挂载 Webhook 路由(生产环境推荐) */
|
|
151
|
-
setupWebhook(router) {
|
|
152
|
-
const bot = this.bots.values().next().value;
|
|
153
|
-
if (!bot?.$config.webhook_secret) {
|
|
154
|
-
this.plugin.logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'missing webhook_secret' }));
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
const secret = bot.$config.webhook_secret;
|
|
158
|
-
const path = bot.$config.webhook_path || '/github/webhook';
|
|
159
|
-
router.post(path, async (ctx) => {
|
|
160
|
-
const signature = ctx.get('x-hub-signature-256');
|
|
161
|
-
const event = ctx.get('x-github-event');
|
|
162
|
-
const deliveryId = ctx.get('x-github-delivery');
|
|
163
|
-
if (!signature || !event) {
|
|
164
|
-
ctx.status = 400;
|
|
165
|
-
ctx.body = { error: 'Missing signature or event header' };
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
// HMAC-SHA256 签名验证
|
|
169
|
-
const body = ctx.request.rawBody || JSON.stringify(ctx.request.body);
|
|
170
|
-
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(body).digest('hex');
|
|
171
|
-
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
|
|
172
|
-
this.plugin.logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'invalid signature', delivery: deliveryId }));
|
|
173
|
-
ctx.status = 401;
|
|
174
|
-
ctx.body = { error: 'Invalid signature' };
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
const payload = ctx.request.body;
|
|
178
|
-
ctx.status = 200;
|
|
179
|
-
ctx.body = { ok: true };
|
|
180
|
-
// 异步处理事件,不阻塞响应
|
|
181
|
-
this.handleWebhookPayload(event, payload).catch(e => this.plugin.logger.error(`Webhook 事件处理失败 (${event}):`, e));
|
|
182
|
-
});
|
|
183
|
-
this._webhookActive = true;
|
|
184
|
-
this.plugin.logger.debug(formatCompact({ op: 'webhook', path }));
|
|
185
|
-
}
|
|
186
|
-
/** 处理 Webhook 推送的事件 */
|
|
187
|
-
async handleWebhookPayload(event, payload) {
|
|
188
|
-
const bot = this.bots.values().next().value;
|
|
189
|
-
const repo = payload.repository?.full_name;
|
|
190
|
-
this.plugin.logger.debug(`Webhook: ${event}${payload.action ? `.${payload.action}` : ''} ${repo || ''}`);
|
|
191
|
-
// 记录事件到数据库
|
|
192
|
-
if (repo) {
|
|
193
|
-
const db = this.plugin.root?.inject('database');
|
|
194
|
-
const eventsModel = db?.models?.get('github_events');
|
|
195
|
-
if (eventsModel) {
|
|
196
|
-
await eventsModel.insert({ id: Date.now(), repo, event_type: event, payload }).catch(() => { });
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
// 处理消息类事件(Issue/PR 评论)
|
|
200
|
-
if (bot && event === 'issue_comment' && payload.action === 'created' && payload.comment) {
|
|
201
|
-
const message = bot.$formatMessage(payload);
|
|
202
|
-
const botUser = bot.gh.authenticatedUser;
|
|
203
|
-
if (!(botUser && message.$sender.id === botUser)) {
|
|
204
|
-
this.emit('message.receive', message);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
if (bot && event === 'pull_request_review_comment' && payload.action === 'created' && payload.comment) {
|
|
208
|
-
const message = bot.formatPRReviewComment(payload);
|
|
209
|
-
const botUser = bot.gh.authenticatedUser;
|
|
210
|
-
if (!(botUser && message.$sender.id === botUser)) {
|
|
211
|
-
this.emit('message.receive', message);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
if (bot && event === 'pull_request_review' && payload.action === 'submitted') {
|
|
215
|
-
const message = bot.formatPRReview(payload);
|
|
216
|
-
if (message) {
|
|
217
|
-
const botUser = bot.gh.authenticatedUser;
|
|
218
|
-
if (!(botUser && message.$sender.id === botUser)) {
|
|
219
|
-
this.emit('message.receive', message);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
// 通知订阅者
|
|
224
|
-
if (repo) {
|
|
225
|
-
const genericPayload = {
|
|
226
|
-
action: payload.action,
|
|
227
|
-
repository: payload.repository,
|
|
228
|
-
sender: payload.sender,
|
|
229
|
-
ref: payload.ref,
|
|
230
|
-
commits: payload.commits,
|
|
231
|
-
issue: payload.issue,
|
|
232
|
-
pull_request: payload.pull_request,
|
|
233
|
-
forkee: payload.forkee,
|
|
234
|
-
};
|
|
235
|
-
await this.dispatchNotification(event, genericPayload);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
// ── 事件轮询 ────────────────────────────────────────────────────
|
|
239
|
-
/** 启动事件轮询 */
|
|
240
|
-
startPolling() {
|
|
241
|
-
if (this._pollTimer)
|
|
242
|
-
return;
|
|
243
|
-
const bot = this.bots.values().next().value;
|
|
244
|
-
const interval = (bot?.$config.poll_interval || 60) * 1000;
|
|
245
|
-
this.plugin.logger.debug(formatCompact({ op: 'poll', interval_s: interval / 1000 }));
|
|
246
|
-
// 立即执行一次
|
|
247
|
-
this.pollAllRepos().catch(e => this.plugin.logger.error('轮询失败:', e));
|
|
248
|
-
this._pollTimer = setInterval(() => {
|
|
249
|
-
this.pollAllRepos().catch(e => this.plugin.logger.error('轮询失败:', e));
|
|
250
|
-
}, interval);
|
|
251
|
-
}
|
|
252
|
-
/** 停止事件轮询 */
|
|
253
|
-
stopPolling() {
|
|
254
|
-
if (this._pollTimer) {
|
|
255
|
-
clearInterval(this._pollTimer);
|
|
256
|
-
this._pollTimer = null;
|
|
257
|
-
this.plugin.logger.debug('GitHub 事件轮询已停止');
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
/** 轮询所有已订阅仓库的事件 */
|
|
261
|
-
async pollAllRepos() {
|
|
262
|
-
const db = this.plugin.root?.inject('database');
|
|
263
|
-
const model = db?.models?.get('github_subscriptions');
|
|
264
|
-
if (!model)
|
|
265
|
-
return;
|
|
266
|
-
// 获取所有不重复的订阅仓库
|
|
267
|
-
const allSubs = await model.select();
|
|
268
|
-
const repos = [...new Set((allSubs || []).map((s) => s.repo))];
|
|
269
|
-
if (!repos.length)
|
|
270
|
-
return;
|
|
271
|
-
const gh = this.getAPI();
|
|
272
|
-
if (!gh)
|
|
273
|
-
return;
|
|
274
|
-
for (const repo of repos) {
|
|
275
|
-
try {
|
|
276
|
-
await this.pollRepoEvents(repo, gh);
|
|
277
|
-
}
|
|
278
|
-
catch (e) {
|
|
279
|
-
this.plugin.logger.warn(formatCompact({ op: 'poll', repo, ok: false, error: String(e) }));
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
/** 轮询单个仓库的事件 */
|
|
284
|
-
async pollRepoEvents(repo, gh) {
|
|
285
|
-
const etag = this._etags.get(repo);
|
|
286
|
-
const { events, etag: newEtag } = await gh.listRepoEvents(repo, etag);
|
|
287
|
-
if (newEtag)
|
|
288
|
-
this._etags.set(repo, newEtag);
|
|
289
|
-
if (!events.length)
|
|
290
|
-
return;
|
|
291
|
-
const lastId = this._lastEventIds.get(repo);
|
|
292
|
-
const newEvents = [];
|
|
293
|
-
for (const ev of events) {
|
|
294
|
-
if (ev.id === lastId)
|
|
295
|
-
break;
|
|
296
|
-
newEvents.push(ev);
|
|
297
|
-
}
|
|
298
|
-
if (!newEvents.length)
|
|
299
|
-
return;
|
|
300
|
-
// 记录最新事件 ID
|
|
301
|
-
this._lastEventIds.set(repo, events[0].id);
|
|
302
|
-
// 首次轮询只记录位置,不触发通知(避免启动时大量回溯)
|
|
303
|
-
if (!lastId) {
|
|
304
|
-
this.plugin.logger.debug(`${repo}: 首次轮询,记录位置 (${events[0].id}),跳过 ${events.length} 条历史事件`);
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
this.plugin.logger.debug(`${repo}: ${newEvents.length} 条新事件`);
|
|
308
|
-
const bot = this.bots.values().next().value;
|
|
309
|
-
// 按时间正序处理(API 返回倒序)
|
|
310
|
-
for (const ev of newEvents.reverse()) {
|
|
311
|
-
const eventName = this.mapEventType(ev.type);
|
|
312
|
-
if (!eventName)
|
|
313
|
-
continue;
|
|
314
|
-
// 构造与 webhook payload 兼容的结构
|
|
315
|
-
const payload = {
|
|
316
|
-
action: ev.payload?.action,
|
|
317
|
-
repository: ev.repo ? { full_name: ev.repo.name, html_url: `https://github.com/${ev.repo.name}`, description: '' } : ev.payload?.repository,
|
|
318
|
-
sender: { login: ev.actor?.login || '?', id: ev.actor?.id || 0, html_url: `https://github.com/${ev.actor?.login}` },
|
|
319
|
-
ref: ev.payload?.ref,
|
|
320
|
-
commits: ev.payload?.commits,
|
|
321
|
-
issue: ev.payload?.issue,
|
|
322
|
-
pull_request: ev.payload?.pull_request,
|
|
323
|
-
forkee: ev.payload?.forkee,
|
|
324
|
-
};
|
|
325
|
-
// 记录事件到数据库
|
|
326
|
-
const db = this.plugin.root?.inject('database');
|
|
327
|
-
const eventsModel = db?.models?.get('github_events');
|
|
328
|
-
if (eventsModel) {
|
|
329
|
-
await eventsModel.insert({ id: Date.now(), repo, event_type: eventName, payload: ev.payload }).catch(() => { });
|
|
330
|
-
}
|
|
331
|
-
// 处理消息类事件(Issue/PR 评论)
|
|
332
|
-
if (bot && eventName === 'issue_comment' && ev.payload?.action === 'created' && ev.payload?.comment) {
|
|
333
|
-
const message = bot.$formatMessage(ev.payload);
|
|
334
|
-
const botUser = bot.gh.authenticatedUser;
|
|
335
|
-
if (!(botUser && message.$sender.id === botUser)) {
|
|
336
|
-
this.emit('message.receive', message);
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
// 通知订阅者
|
|
340
|
-
await this.dispatchNotification(eventName, payload);
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
/** Events API type → webhook event name */
|
|
344
|
-
mapEventType(type) {
|
|
345
|
-
const map = {
|
|
346
|
-
PushEvent: 'push',
|
|
347
|
-
IssuesEvent: 'issues',
|
|
348
|
-
WatchEvent: 'star',
|
|
349
|
-
ForkEvent: 'fork',
|
|
350
|
-
PullRequestEvent: 'pull_request',
|
|
351
|
-
IssueCommentEvent: 'issue_comment',
|
|
352
|
-
PullRequestReviewEvent: 'pull_request_review',
|
|
353
|
-
PullRequestReviewCommentEvent: 'pull_request_review_comment',
|
|
354
|
-
};
|
|
355
|
-
return map[type] || null;
|
|
356
|
-
}
|
|
357
|
-
// ── 通知推送 ───────────────────────────────────────────────────────
|
|
358
|
-
async dispatchNotification(eventName, payload) {
|
|
359
|
-
let eventType = null;
|
|
360
|
-
switch (eventName) {
|
|
361
|
-
case 'push':
|
|
362
|
-
eventType = 'push';
|
|
363
|
-
break;
|
|
364
|
-
case 'issues':
|
|
365
|
-
eventType = 'issue';
|
|
366
|
-
break;
|
|
367
|
-
case 'star':
|
|
368
|
-
eventType = payload.action === 'deleted' ? 'unstar' : 'star';
|
|
369
|
-
break;
|
|
370
|
-
case 'fork':
|
|
371
|
-
eventType = 'fork';
|
|
372
|
-
break;
|
|
373
|
-
case 'pull_request':
|
|
374
|
-
eventType = 'pull_request';
|
|
375
|
-
break;
|
|
376
|
-
}
|
|
377
|
-
if (!eventType) {
|
|
378
|
-
this.plugin.logger.debug(`dispatchNotification: 未知事件 ${eventName},跳过`);
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
381
|
-
const repo = payload.repository.full_name;
|
|
382
|
-
const db = this.plugin.root?.inject('database');
|
|
383
|
-
const model = db?.models?.get('github_subscriptions');
|
|
384
|
-
if (!model) {
|
|
385
|
-
this.plugin.logger.warn(formatCompact({ op: 'notify', ok: false, error: 'subscriptions model not ready' }));
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
const subs = await model.select().where({ repo });
|
|
389
|
-
this.plugin.logger.debug(`dispatchNotification: ${repo} ${eventName}(${eventType}) — 找到 ${subs?.length || 0} 条订阅`);
|
|
390
|
-
if (!subs?.length)
|
|
391
|
-
return;
|
|
392
|
-
const text = formatNotification(eventName, payload);
|
|
393
|
-
for (const sub of subs) {
|
|
394
|
-
const s = sub;
|
|
395
|
-
const events = safeParseEvents(s.events);
|
|
396
|
-
if (!events.includes(eventType)) {
|
|
397
|
-
this.plugin.logger.debug(`dispatchNotification: ${s.adapter}:${s.target_id} 未订阅 ${eventType},跳过`);
|
|
398
|
-
continue;
|
|
399
|
-
}
|
|
400
|
-
try {
|
|
401
|
-
const targetAdapter = this.plugin.root?.inject(s.adapter);
|
|
402
|
-
if (!(targetAdapter instanceof Adapter)) {
|
|
403
|
-
this.plugin.logger.warn(formatCompact({ op: 'notify', ok: false, adapter: s.adapter, error: 'no sendMessage' }));
|
|
404
|
-
continue;
|
|
405
|
-
}
|
|
406
|
-
this.plugin.logger.debug(formatCompact({ op: 'notify', event: eventType, adapter: s.adapter, bot: s.bot, target: s.target_id }));
|
|
407
|
-
await targetAdapter.sendMessage({ context: s.adapter, bot: s.bot, id: s.target_id, type: s.target_type, content: text });
|
|
408
|
-
}
|
|
409
|
-
catch (e) {
|
|
410
|
-
this.plugin.logger.error(`通知推送失败 → ${s.adapter}:${s.target_id}`, e);
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
//# sourceMappingURL=adapter.js.map
|