@zhin.js/adapter-github 1.0.1 → 1.1.2
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 +1212 -0
- package/README.md +73 -195
- package/adapters/github/index.js +51 -0
- package/adapters/github/index.ts +59 -0
- package/agents/github/agent.json +20 -0
- package/agents/github/boundaries.md +3 -0
- package/agents/github/conventions.md +3 -0
- package/agents/github/skills/github-account/SKILL.md +20 -0
- package/agents/github/skills/github-account/handlers.js +116 -0
- package/agents/github/skills/github-account/handlers.ts +136 -0
- package/agents/github/skills/github-account/tools/bind/index.js +13 -0
- package/agents/github/skills/github-account/tools/bind/index.ts +13 -0
- package/agents/github/skills/github-account/tools/install/index.js +13 -0
- package/agents/github/skills/github-account/tools/install/index.ts +13 -0
- package/agents/github/skills/github-account/tools/unbind/index.js +13 -0
- package/agents/github/skills/github-account/tools/unbind/index.ts +13 -0
- package/agents/github/skills/github-account/tools/whoami/index.js +13 -0
- package/agents/github/skills/github-account/tools/whoami/index.ts +13 -0
- package/agents/github/skills/github-cli/SKILL.md +47 -0
- package/agents/github/skills/github-repository/SKILL.md +21 -0
- package/agents/github/skills/github-repository/handlers.js +77 -0
- package/agents/github/skills/github-repository/handlers.ts +113 -0
- package/agents/github/skills/github-repository/tools/create_pr/index.js +20 -0
- package/agents/github/skills/github-repository/tools/create_pr/index.ts +20 -0
- package/agents/github/skills/github-repository/tools/patch_file/index.js +19 -0
- package/agents/github/skills/github-repository/tools/patch_file/index.ts +19 -0
- package/agents/github/skills/github-repository/tools/prepare_workspace/index.js +15 -0
- package/agents/github/skills/github-repository/tools/prepare_workspace/index.ts +15 -0
- package/agents/github/skills/github-repository/tools/push_branch/index.js +18 -0
- package/agents/github/skills/github-repository/tools/push_branch/index.ts +18 -0
- package/agents/github/skills/github-subscriptions/SKILL.md +20 -0
- package/agents/github/skills/github-subscriptions/handlers.js +101 -0
- package/agents/github/skills/github-subscriptions/handlers.ts +119 -0
- package/agents/github/skills/github-subscriptions/tools/star/index.js +16 -0
- package/agents/github/skills/github-subscriptions/tools/star/index.ts +16 -0
- package/agents/github/skills/github-subscriptions/tools/subscribe/index.js +16 -0
- package/agents/github/skills/github-subscriptions/tools/subscribe/index.ts +16 -0
- package/agents/github/skills/github-subscriptions/tools/subscriptions/index.js +13 -0
- package/agents/github/skills/github-subscriptions/tools/subscriptions/index.ts +13 -0
- package/agents/github/skills/github-subscriptions/tools/unsubscribe/index.js +15 -0
- package/agents/github/skills/github-subscriptions/tools/unsubscribe/index.ts +15 -0
- package/agents/github/system.md +3 -0
- package/commands/github/endpoint/add/[id]/index.js +3 -0
- package/commands/github/endpoint/add/[id]/index.ts +3 -0
- package/commands/github/endpoint/definition.js +23 -0
- package/commands/github/endpoint/definition.ts +23 -0
- package/commands/github/endpoint/list/index.js +3 -0
- package/commands/github/endpoint/list/index.ts +3 -0
- package/commands/github/endpoint/remove/[id]/index.js +3 -0
- package/commands/github/endpoint/remove/[id]/index.ts +3 -0
- package/lib/client.d.ts +36 -0
- package/lib/client.js +44 -0
- package/lib/endpoint.d.ts +32 -22
- package/lib/endpoint.js +125 -145
- package/lib/gh-client.d.ts +73 -1
- package/lib/gh-client.js +99 -1
- package/lib/github-channel-context.d.ts +16 -0
- package/lib/github-channel-context.js +31 -0
- package/lib/github-runtime-state.d.ts +1 -0
- package/lib/github-runtime-state.js +6 -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 +72 -0
- package/lib/protocol.js +281 -0
- package/lib/types.d.ts +0 -18
- package/lib/types.js +2 -4
- 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 +93 -23
- package/plugin.js +38 -0
- package/prompt-sections/platform/index.js +16 -0
- package/prompt-sections/platform/index.ts +16 -0
- package/schema.json +137 -0
- package/src/client.ts +62 -0
- package/src/endpoint.ts +145 -150
- package/src/gh-client.ts +131 -0
- package/src/github-channel-context.ts +46 -0
- package/src/github-runtime-state.ts +7 -0
- package/src/index.ts +48 -431
- package/src/oauth-users.ts +47 -0
- package/src/protocol.ts +372 -0
- package/src/types.ts +0 -22
- package/src/webhook.ts +130 -0
- package/src/workspace-manager.ts +168 -0
- package/lib/adapter.d.ts +0 -64
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -416
- 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/endpoint.d.ts.map +0 -1
- package/lib/endpoint.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/skills/github/SKILL.md +0 -266
- package/src/adapter.ts +0 -448
- package/src/agent-prompt.ts +0 -99
- package/src/register-github-mcp.ts +0 -60
package/lib/protocol.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub webhook / comment helpers — no legacy Adapter/Endpoint / segment-mapper.
|
|
3
|
+
* Canonicalization is owned by gateway/core before endpoint.send.
|
|
4
|
+
*/
|
|
5
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
6
|
+
import { isMediaRef } from '@zhin.js/im-contract';
|
|
7
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
8
|
+
const logger = getLogger('github');
|
|
9
|
+
import { buildChannelId, } from './types.js';
|
|
10
|
+
export { buildChannelId, parseChannelId } from './types.js';
|
|
11
|
+
/**
|
|
12
|
+
* 入站归一化 → ConversationRef:Issue/PR 评论区即仓库容器内的 channel 会话,
|
|
13
|
+
* 仓库(owner/repo)进 `parent`。出站侧用 `parseChannelId(conversation.id)`
|
|
14
|
+
* 在平台边界还原 repo/type/number。
|
|
15
|
+
*/
|
|
16
|
+
export function githubInboundConversation(endpointKey, comment) {
|
|
17
|
+
return {
|
|
18
|
+
endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
|
|
19
|
+
kind: 'channel',
|
|
20
|
+
id: comment.channelId,
|
|
21
|
+
parent: { kind: 'channel', id: comment.repo },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function resolveGithubConfig(config) {
|
|
25
|
+
const id = typeof config.id === 'string' ? config.id.trim() : '';
|
|
26
|
+
if (!id)
|
|
27
|
+
throw new TypeError('GitHub endpoint requires a non-empty id');
|
|
28
|
+
const appIdRaw = config.app_id != null ? String(config.app_id).trim() : '';
|
|
29
|
+
const appId = appIdRaw ? (Number(appIdRaw) || appIdRaw) : undefined;
|
|
30
|
+
const privateKey = typeof config.private_key === 'string'
|
|
31
|
+
? config.private_key.trim()
|
|
32
|
+
: '';
|
|
33
|
+
if (!appId || !privateKey) {
|
|
34
|
+
throw new TypeError('GitHub endpoint requires app_id and private_key');
|
|
35
|
+
}
|
|
36
|
+
const webhookSecret = config.webhook_secret;
|
|
37
|
+
const host = config.host;
|
|
38
|
+
const webhookPath = normalizeWebhookPath(config.webhook_path ?? '/github/webhook');
|
|
39
|
+
const autoReplyRepos = [...(config.auto_reply_repos ?? [])];
|
|
40
|
+
const botLogin = config.bot_login;
|
|
41
|
+
const workspaceRoot = config.workspace_root ?? 'data/github-workspaces';
|
|
42
|
+
return {
|
|
43
|
+
context: 'github',
|
|
44
|
+
id,
|
|
45
|
+
...(host ? { host } : {}),
|
|
46
|
+
appId,
|
|
47
|
+
privateKey,
|
|
48
|
+
...(webhookSecret ? { webhookSecret } : {}),
|
|
49
|
+
webhookPath,
|
|
50
|
+
autoReplyRepos,
|
|
51
|
+
...(botLogin ? { botLogin } : {}),
|
|
52
|
+
workspaceRoot,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function normalizeWebhookPath(path) {
|
|
56
|
+
const trimmed = path.trim() || '/github/webhook';
|
|
57
|
+
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
58
|
+
}
|
|
59
|
+
export function shouldAutoReplyRepo(config, repo) {
|
|
60
|
+
const list = config.autoReplyRepos;
|
|
61
|
+
if (!list.length)
|
|
62
|
+
return false;
|
|
63
|
+
const key = repo.toLowerCase();
|
|
64
|
+
return list.some((r) => r.toLowerCase() === key);
|
|
65
|
+
}
|
|
66
|
+
/** Prepend synthetic @bot for auto_reply repos (gateway text path). */
|
|
67
|
+
export function enrichInboundContent(content, config, botLogin, repo) {
|
|
68
|
+
const login = config.botLogin || botLogin;
|
|
69
|
+
if (!login || !shouldAutoReplyRepo(config, repo))
|
|
70
|
+
return content;
|
|
71
|
+
if (content.includes(`@${login}`))
|
|
72
|
+
return content;
|
|
73
|
+
return `@${login} ${content}`;
|
|
74
|
+
}
|
|
75
|
+
/** Build inbound text for OutboundMessageService.receive from markdown/comment body. */
|
|
76
|
+
export function formatInboundContent(body) {
|
|
77
|
+
return body;
|
|
78
|
+
}
|
|
79
|
+
/** Build markdown body for Issue/PR comment send. */
|
|
80
|
+
export function formatOutboundBody(payload) {
|
|
81
|
+
if (typeof payload === 'string')
|
|
82
|
+
return payload;
|
|
83
|
+
if (payload == null)
|
|
84
|
+
return '';
|
|
85
|
+
if (!Array.isArray(payload)) {
|
|
86
|
+
if (typeof payload === 'object' && payload !== null && 'text' in payload) {
|
|
87
|
+
return String(payload.text ?? '');
|
|
88
|
+
}
|
|
89
|
+
return String(payload);
|
|
90
|
+
}
|
|
91
|
+
return payload.map((seg) => {
|
|
92
|
+
if (typeof seg === 'string')
|
|
93
|
+
return seg;
|
|
94
|
+
const item = seg;
|
|
95
|
+
switch (item.type) {
|
|
96
|
+
case 'text':
|
|
97
|
+
return String(item.data?.text ?? '');
|
|
98
|
+
case 'mention':
|
|
99
|
+
case 'at':
|
|
100
|
+
return `@${item.data?.name || item.data?.id || item.data?.target || ''}`;
|
|
101
|
+
case 'image':
|
|
102
|
+
case 'audio':
|
|
103
|
+
case 'video':
|
|
104
|
+
case 'file':
|
|
105
|
+
return formatMediaSegment(item);
|
|
106
|
+
case 'link':
|
|
107
|
+
return `[${item.data?.text || item.data?.url || ''}](${item.data?.url || ''})`;
|
|
108
|
+
default:
|
|
109
|
+
return String(item.data?.text ?? `[${item.type}]`);
|
|
110
|
+
}
|
|
111
|
+
}).join('');
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* 媒体段 → GitHub markdown(canonical MediaRef 唯一来源)。
|
|
115
|
+
* GitHub 评论只能消费远程 URL(无上传面),kind=url 直发;
|
|
116
|
+
* base64 / path / file 不可投递,warn + 丢弃。
|
|
117
|
+
*/
|
|
118
|
+
function formatMediaSegment(seg) {
|
|
119
|
+
const media = seg.data?.media;
|
|
120
|
+
if (!isMediaRef(media)) {
|
|
121
|
+
logger.warn(formatCompact({
|
|
122
|
+
op: 'github_outbound_media_dropped',
|
|
123
|
+
type: seg.type,
|
|
124
|
+
reason: 'missing_media_ref',
|
|
125
|
+
}));
|
|
126
|
+
return '';
|
|
127
|
+
}
|
|
128
|
+
if (media.kind !== 'url') {
|
|
129
|
+
logger.warn(formatCompact({
|
|
130
|
+
op: 'github_outbound_media_dropped',
|
|
131
|
+
type: seg.type,
|
|
132
|
+
reason: `unsupported_kind:${media.kind}`,
|
|
133
|
+
}));
|
|
134
|
+
return '';
|
|
135
|
+
}
|
|
136
|
+
if (seg.type === 'image') {
|
|
137
|
+
const alt = typeof seg.data?.alt === 'string' && seg.data.alt
|
|
138
|
+
? seg.data.alt
|
|
139
|
+
: media.file_name || 'image';
|
|
140
|
+
return ``;
|
|
141
|
+
}
|
|
142
|
+
const label = media.file_name || seg.type;
|
|
143
|
+
return `[${label}](${media.value})`;
|
|
144
|
+
}
|
|
145
|
+
export function verifyWebhookSignature(secret, rawBody, signatureHeader) {
|
|
146
|
+
if (!signatureHeader?.startsWith('sha256='))
|
|
147
|
+
return false;
|
|
148
|
+
const expected = `sha256=${createHmac('sha256', secret).update(rawBody).digest('hex')}`;
|
|
149
|
+
try {
|
|
150
|
+
const a = Buffer.from(signatureHeader);
|
|
151
|
+
const b = Buffer.from(expected);
|
|
152
|
+
if (a.length !== b.length)
|
|
153
|
+
return false;
|
|
154
|
+
return timingSafeEqual(a, b);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
export function headerValue(headers, name) {
|
|
161
|
+
const raw = headers[name] ?? headers[name.toLowerCase()];
|
|
162
|
+
if (Array.isArray(raw))
|
|
163
|
+
return raw[0] ?? '';
|
|
164
|
+
return raw ?? '';
|
|
165
|
+
}
|
|
166
|
+
export async function readTextBody(request, options = {}) {
|
|
167
|
+
const limit = options.limit ?? 1_048_576;
|
|
168
|
+
const chunks = [];
|
|
169
|
+
let size = 0;
|
|
170
|
+
for await (const chunk of request) {
|
|
171
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
172
|
+
size += buffer.length;
|
|
173
|
+
if (size > limit) {
|
|
174
|
+
request.destroy();
|
|
175
|
+
throw new Error(`Request body exceeds ${limit} bytes`);
|
|
176
|
+
}
|
|
177
|
+
chunks.push(buffer);
|
|
178
|
+
}
|
|
179
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
180
|
+
}
|
|
181
|
+
export function parseIssueCommentInbound(payload) {
|
|
182
|
+
if (payload.action !== 'created' || !payload.comment)
|
|
183
|
+
return null;
|
|
184
|
+
const repo = payload.repository.full_name;
|
|
185
|
+
const isPR = 'pull_request' in payload.issue;
|
|
186
|
+
return {
|
|
187
|
+
id: String(payload.comment.id),
|
|
188
|
+
channelId: buildChannelId(repo, isPR ? 'pr' : 'issue', payload.issue.number),
|
|
189
|
+
sender: payload.sender.login,
|
|
190
|
+
content: payload.comment.body,
|
|
191
|
+
repo,
|
|
192
|
+
kind: 'issue_comment',
|
|
193
|
+
createdAt: new Date(payload.comment.created_at).getTime(),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
export function parsePrReviewCommentInbound(payload) {
|
|
197
|
+
if (payload.action !== 'created' || !payload.comment)
|
|
198
|
+
return null;
|
|
199
|
+
const repo = payload.repository.full_name;
|
|
200
|
+
const body = payload.comment.path
|
|
201
|
+
? `**${payload.comment.path}**\n${payload.comment.diff_hunk ? `\`\`\`diff\n${payload.comment.diff_hunk}\n\`\`\`\n` : ''}${payload.comment.body}`
|
|
202
|
+
: payload.comment.body;
|
|
203
|
+
return {
|
|
204
|
+
id: String(payload.comment.id),
|
|
205
|
+
channelId: buildChannelId(repo, 'pr', payload.pull_request.number),
|
|
206
|
+
sender: payload.sender.login,
|
|
207
|
+
content: body,
|
|
208
|
+
repo,
|
|
209
|
+
kind: 'pr_review_comment',
|
|
210
|
+
createdAt: new Date(payload.comment.created_at).getTime(),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
export function parsePrReviewInbound(payload) {
|
|
214
|
+
if (payload.action !== 'submitted' || !payload.review.body)
|
|
215
|
+
return null;
|
|
216
|
+
const repo = payload.repository.full_name;
|
|
217
|
+
const stateLabel = {
|
|
218
|
+
approved: '✅ APPROVED',
|
|
219
|
+
changes_requested: '🔄 CHANGES REQUESTED',
|
|
220
|
+
commented: '💬 COMMENTED',
|
|
221
|
+
dismissed: '❌ DISMISSED',
|
|
222
|
+
};
|
|
223
|
+
const body = `**[${stateLabel[payload.review.state] || payload.review.state}]**\n${payload.review.body}`;
|
|
224
|
+
return {
|
|
225
|
+
id: String(payload.review.id),
|
|
226
|
+
channelId: buildChannelId(repo, 'pr', payload.pull_request.number),
|
|
227
|
+
sender: payload.sender.login,
|
|
228
|
+
content: body,
|
|
229
|
+
repo,
|
|
230
|
+
kind: 'pr_review',
|
|
231
|
+
createdAt: new Date(payload.review.submitted_at).getTime(),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
export function formatNotification(event, p) {
|
|
235
|
+
const repo = p.repository.full_name;
|
|
236
|
+
const sender = p.sender.login;
|
|
237
|
+
const repoUrl = p.repository.html_url;
|
|
238
|
+
switch (event) {
|
|
239
|
+
case 'push': {
|
|
240
|
+
const branch = p.ref?.replace('refs/heads/', '') || '?';
|
|
241
|
+
const commits = p.commits || [];
|
|
242
|
+
const compareUrl = commits.length >= 2
|
|
243
|
+
? `${repoUrl}/compare/${commits[0].id.substring(0, 12)}...${commits[commits.length - 1].id.substring(0, 12)}`
|
|
244
|
+
: commits.length === 1 ? `${repoUrl}/commit/${commits[0].id}` : '';
|
|
245
|
+
let msg = `📦 ${repo}\n🌿 ${sender} pushed ${commits.length} commit(s) to \`${branch}\`\n`;
|
|
246
|
+
if (commits.length) {
|
|
247
|
+
msg += '\n';
|
|
248
|
+
msg += commits.slice(0, 5).map((c) => ` • [\`${c.id.substring(0, 7)}\`](${repoUrl}/commit/${c.id}) ${c.message.split('\n')[0]}`).join('\n');
|
|
249
|
+
if (commits.length > 5)
|
|
250
|
+
msg += `\n ... +${commits.length - 5} more`;
|
|
251
|
+
}
|
|
252
|
+
if (compareUrl)
|
|
253
|
+
msg += `\n\n🔗 ${compareUrl}`;
|
|
254
|
+
return msg;
|
|
255
|
+
}
|
|
256
|
+
case 'issues': {
|
|
257
|
+
const i = p.issue;
|
|
258
|
+
const act = p.action === 'opened' ? '📝 opened' : p.action === 'closed' ? '✅ closed' : `🔄 ${p.action || 'updated'}`;
|
|
259
|
+
return `🐛 ${repo}\n👤 ${sender} ${act} issue #${i.number}\n📌 ${i.title}\n🔗 ${i.html_url}`;
|
|
260
|
+
}
|
|
261
|
+
case 'star': {
|
|
262
|
+
const starred = p.action !== 'deleted';
|
|
263
|
+
return `${starred ? '⭐' : '💔'} ${repo}\n👤 ${sender} ${starred ? 'starred' : 'unstarred'}\n🔗 ${repoUrl}`;
|
|
264
|
+
}
|
|
265
|
+
case 'fork':
|
|
266
|
+
return `🍴 ${repo}\n👤 ${sender} forked → ${p.forkee.full_name}\n🔗 ${p.forkee.html_url}`;
|
|
267
|
+
case 'pull_request': {
|
|
268
|
+
const pr = p.pull_request;
|
|
269
|
+
const act = p.action === 'opened' ? '📝 opened'
|
|
270
|
+
: p.action === 'closed' ? (pr.state === 'closed' ? '❌ closed' : '✅ merged')
|
|
271
|
+
: `🔄 ${p.action || 'updated'}`;
|
|
272
|
+
return `🔀 ${repo}\n👤 ${sender} ${act} PR #${pr.number}\n📌 ${pr.title}\n🌿 ${pr.head.ref} → ${pr.base.ref}\n🔗 ${pr.html_url}`;
|
|
273
|
+
}
|
|
274
|
+
default:
|
|
275
|
+
return `📬 ${repo}\n📡 ${event}${p.action ? ` (${p.action})` : ''} by ${sender}\n🔗 ${repoUrl}`;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/** Parse @mentions in markdown into a flat display string (gateway text path). */
|
|
279
|
+
export function parseMarkdownMentions(md) {
|
|
280
|
+
return md;
|
|
281
|
+
}
|
package/lib/types.d.ts
CHANGED
|
@@ -1,20 +1,3 @@
|
|
|
1
|
-
export interface GitHubEndpointConfig {
|
|
2
|
-
context: 'github';
|
|
3
|
-
/** Endpoint 标识名称 */
|
|
4
|
-
name: string;
|
|
5
|
-
/** GitHub Enterprise 主机名(默认 github.com) */
|
|
6
|
-
host?: string;
|
|
7
|
-
/** GitHub App ID */
|
|
8
|
-
app_id?: string | number;
|
|
9
|
-
/** GitHub App 私钥(PEM 内容或文件路径) */
|
|
10
|
-
private_key?: string;
|
|
11
|
-
/** Webhook Secret(配置后启用 Webhook 接收事件,不配置则使用轮询) */
|
|
12
|
-
webhook_secret?: string;
|
|
13
|
-
/** Webhook 路由路径(默认 /github/webhook) */
|
|
14
|
-
webhook_path?: string;
|
|
15
|
-
/** 事件轮询间隔(秒,默认 60,Webhook 模式下作为降级备选) */
|
|
16
|
-
poll_interval?: number;
|
|
17
|
-
}
|
|
18
1
|
export interface ParsedChannel {
|
|
19
2
|
repo: string;
|
|
20
3
|
type: 'issue' | 'pr';
|
|
@@ -141,4 +124,3 @@ export interface GitHubOAuthUser {
|
|
|
141
124
|
/** 绑定时间 (ms) */
|
|
142
125
|
created_at: number;
|
|
143
126
|
}
|
|
144
|
-
//# sourceMappingURL=types.d.ts.map
|
package/lib/types.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
// ──
|
|
2
|
-
//
|
|
3
|
-
// 认证方式:gh auth login
|
|
1
|
+
// ── Channel ID ───────────────────────────────────────────────────────
|
|
2
|
+
// 格式: {owner}/{repo}/issues/{number} 或 {owner}/{repo}/pull/{number}
|
|
4
3
|
export function parseChannelId(channelId) {
|
|
5
4
|
const issueMatch = channelId.match(/^(.+?\/.+?)\/issues\/(\d+)$/);
|
|
6
5
|
if (issueMatch)
|
|
@@ -13,4 +12,3 @@ export function parseChannelId(channelId) {
|
|
|
13
12
|
export function buildChannelId(repo, type, number) {
|
|
14
13
|
return type === 'issue' ? `${repo}/issues/${number}` : `${repo}/pull/${number}`;
|
|
15
14
|
}
|
|
16
|
-
//# sourceMappingURL=types.js.map
|
package/lib/webhook.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub webhook HTTP: verify → parse → admit inbound comments.
|
|
3
|
+
*/
|
|
4
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
5
|
+
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
6
|
+
import { type GithubInboundComment, type ResolvedGithubConfig } from './protocol.js';
|
|
7
|
+
export interface GithubWebhookHandler {
|
|
8
|
+
readonly config: ResolvedGithubConfig;
|
|
9
|
+
admitPlatform(name: string, event: unknown): void;
|
|
10
|
+
admit(comment: GithubInboundComment): void;
|
|
11
|
+
}
|
|
12
|
+
export declare function registerGithubWebhookRoutes(http: HttpHost, handler: GithubWebhookHandler): HttpRouteRegistration[];
|
|
13
|
+
export declare function handleGithubWebhookRequest(request: IncomingMessage, response: ServerResponse, handler: GithubWebhookHandler): Promise<void>;
|
|
14
|
+
export declare function dispatchGithubWebhookPayload(event: string, payload: unknown, handler: GithubWebhookHandler): Promise<void>;
|
package/lib/webhook.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
2
|
+
import { headerValue, parseIssueCommentInbound, parsePrReviewCommentInbound, parsePrReviewInbound, readTextBody, verifyWebhookSignature, } from './protocol.js';
|
|
3
|
+
const logger = getLogger('github');
|
|
4
|
+
export function registerGithubWebhookRoutes(http, handler) {
|
|
5
|
+
const routePath = handler.config.webhookPath;
|
|
6
|
+
return [
|
|
7
|
+
http.route('POST', routePath, async (request, response) => {
|
|
8
|
+
await handleGithubWebhookRequest(request, response, handler);
|
|
9
|
+
}, { summary: 'GitHub webhook', tags: ['github'] }),
|
|
10
|
+
];
|
|
11
|
+
}
|
|
12
|
+
export async function handleGithubWebhookRequest(request, response, handler) {
|
|
13
|
+
const secret = handler.config.webhookSecret;
|
|
14
|
+
if (!secret) {
|
|
15
|
+
response.writeHead(503, { 'Content-Type': 'application/json' });
|
|
16
|
+
response.end(JSON.stringify({ error: 'Webhook not configured' }));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const signature = headerValue(request.headers, 'x-hub-signature-256');
|
|
20
|
+
const event = headerValue(request.headers, 'x-github-event');
|
|
21
|
+
const deliveryId = headerValue(request.headers, 'x-github-delivery');
|
|
22
|
+
if (!signature || !event) {
|
|
23
|
+
response.writeHead(400, { 'Content-Type': 'application/json' });
|
|
24
|
+
response.end(JSON.stringify({ error: 'Missing signature or event header' }));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const rawBody = await readTextBody(request);
|
|
29
|
+
if (!verifyWebhookSignature(secret, rawBody, signature)) {
|
|
30
|
+
logger.warn(formatCompact({
|
|
31
|
+
op: 'webhook',
|
|
32
|
+
ok: false,
|
|
33
|
+
error: 'invalid signature',
|
|
34
|
+
delivery: deliveryId,
|
|
35
|
+
}));
|
|
36
|
+
response.writeHead(401, { 'Content-Type': 'application/json' });
|
|
37
|
+
response.end(JSON.stringify({ error: 'Invalid signature' }));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
let payload;
|
|
41
|
+
try {
|
|
42
|
+
payload = rawBody ? JSON.parse(rawBody) : {};
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
response.writeHead(400, { 'Content-Type': 'application/json' });
|
|
46
|
+
response.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
50
|
+
response.end(JSON.stringify({ ok: true }));
|
|
51
|
+
void dispatchGithubWebhookPayload(event, payload, handler).catch((e) => {
|
|
52
|
+
logger.error(`Webhook 事件处理失败 (${event}):`, e);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
logger.error('Webhook error:', error);
|
|
57
|
+
if (!response.headersSent) {
|
|
58
|
+
response.writeHead(500, { 'Content-Type': 'application/json' });
|
|
59
|
+
response.end(JSON.stringify({ error: 'Internal Server Error' }));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export async function dispatchGithubWebhookPayload(event, payload, handler) {
|
|
64
|
+
if (!payload || typeof payload !== 'object')
|
|
65
|
+
return;
|
|
66
|
+
const body = payload;
|
|
67
|
+
const repo = body.repository?.full_name;
|
|
68
|
+
logger.debug(`Webhook: ${event}${body.action ? `.${body.action}` : ''} ${repo || ''}`);
|
|
69
|
+
handler.admitPlatform(typeof body.action === 'string' && body.action ? `${event}.${body.action}` : event, payload);
|
|
70
|
+
if (event === 'issue_comment') {
|
|
71
|
+
const inbound = parseIssueCommentInbound(payload);
|
|
72
|
+
if (inbound)
|
|
73
|
+
handler.admit(inbound);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (event === 'pull_request_review_comment') {
|
|
77
|
+
const inbound = parsePrReviewCommentInbound(payload);
|
|
78
|
+
if (inbound)
|
|
79
|
+
handler.admit(inbound);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (event === 'pull_request_review') {
|
|
83
|
+
const inbound = parsePrReviewInbound(payload);
|
|
84
|
+
if (inbound)
|
|
85
|
+
handler.admit(inbound);
|
|
86
|
+
}
|
|
87
|
+
// Cross-adapter subscription fan-out deferred (needs multi-adapter send in Runtime).
|
|
88
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { GhClient, GitHubBotIdentity } from './gh-client.js';
|
|
2
|
+
/**
|
|
3
|
+
* 校验 GitHub "owner/name" 全名,防止路径穿越与 git 选项注入。
|
|
4
|
+
* 返回通过校验的原值,便于调用方直接使用「已消毒」的变量。
|
|
5
|
+
*/
|
|
6
|
+
export declare function assertRepoFullName(repo: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* 校验 git ref 名称,拒绝选项注入(`-` 前缀)与 git 非法字符。
|
|
9
|
+
*/
|
|
10
|
+
export declare function assertGitRefName(ref: string): string;
|
|
11
|
+
export declare class WorkspaceManager {
|
|
12
|
+
private readonly gh;
|
|
13
|
+
private readonly rootDir;
|
|
14
|
+
constructor(gh: GhClient, rootDir: string);
|
|
15
|
+
getRepoPath(repo: string): string;
|
|
16
|
+
private runGit;
|
|
17
|
+
configureBotGit(repoPath: string, identity: GitHubBotIdentity): Promise<void>;
|
|
18
|
+
ensureRepo(repo: string): Promise<string>;
|
|
19
|
+
checkoutBranch(repo: string, branch: string, baseRef: string): Promise<string>;
|
|
20
|
+
commitAndPush(repo: string, branch: string, message: string): Promise<string>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
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
|
+
const REPO_FULL_NAME_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
8
|
+
const GIT_REF_ILLEGAL_CHARS = new Set('~^:?*[\\`;');
|
|
9
|
+
function containsIllegalGitRefCharacter(ref) {
|
|
10
|
+
return [...ref].some((character) => {
|
|
11
|
+
const code = character.charCodeAt(0);
|
|
12
|
+
return code <= 31
|
|
13
|
+
|| code === 127
|
|
14
|
+
|| /\s/u.test(character)
|
|
15
|
+
|| GIT_REF_ILLEGAL_CHARS.has(character);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* 校验 GitHub "owner/name" 全名,防止路径穿越与 git 选项注入。
|
|
20
|
+
* 返回通过校验的原值,便于调用方直接使用「已消毒」的变量。
|
|
21
|
+
*/
|
|
22
|
+
export function assertRepoFullName(repo) {
|
|
23
|
+
if (typeof repo !== 'string' ||
|
|
24
|
+
!REPO_FULL_NAME_RE.test(repo) ||
|
|
25
|
+
repo.split('/').some((seg) => seg === '' || seg.startsWith('-') || /^\.+$/.test(seg))) {
|
|
26
|
+
throw new TypeError(`非法的仓库全名: ${JSON.stringify(repo)}`);
|
|
27
|
+
}
|
|
28
|
+
return repo;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 校验 git ref 名称,拒绝选项注入(`-` 前缀)与 git 非法字符。
|
|
32
|
+
*/
|
|
33
|
+
export function assertGitRefName(ref) {
|
|
34
|
+
if (typeof ref !== 'string' || ref.length === 0) {
|
|
35
|
+
throw new TypeError(`非法的 git ref: ${JSON.stringify(ref)}`);
|
|
36
|
+
}
|
|
37
|
+
if (ref.startsWith('-') ||
|
|
38
|
+
ref.includes('..') ||
|
|
39
|
+
containsIllegalGitRefCharacter(ref) ||
|
|
40
|
+
ref.endsWith('/') ||
|
|
41
|
+
ref.endsWith('.')) {
|
|
42
|
+
throw new TypeError(`非法的 git ref: ${JSON.stringify(ref)}`);
|
|
43
|
+
}
|
|
44
|
+
return ref;
|
|
45
|
+
}
|
|
46
|
+
export class WorkspaceManager {
|
|
47
|
+
gh;
|
|
48
|
+
rootDir;
|
|
49
|
+
constructor(gh, rootDir) {
|
|
50
|
+
this.gh = gh;
|
|
51
|
+
this.rootDir = rootDir;
|
|
52
|
+
}
|
|
53
|
+
getRepoPath(repo) {
|
|
54
|
+
repo = assertRepoFullName(repo);
|
|
55
|
+
const [owner, name] = repo.split('/');
|
|
56
|
+
return path.join(this.rootDir, owner, name);
|
|
57
|
+
}
|
|
58
|
+
runGit(cwd, args, env) {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const proc = spawn('git', args, {
|
|
61
|
+
cwd,
|
|
62
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
63
|
+
env: { ...process.env, ...env },
|
|
64
|
+
});
|
|
65
|
+
let stdout = '';
|
|
66
|
+
let stderr = '';
|
|
67
|
+
proc.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
68
|
+
proc.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
69
|
+
proc.on('error', (err) => {
|
|
70
|
+
if (err.code === 'ENOENT')
|
|
71
|
+
reject(new Error('git 未安装'));
|
|
72
|
+
else
|
|
73
|
+
reject(err);
|
|
74
|
+
});
|
|
75
|
+
proc.on('close', (code) => {
|
|
76
|
+
if (code === 0)
|
|
77
|
+
resolve(stdout);
|
|
78
|
+
else
|
|
79
|
+
reject(new Error(stderr.trim() || `git ${args.join(' ')} failed (${code})`));
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
configureBotGit(repoPath, identity) {
|
|
84
|
+
return Promise.all([
|
|
85
|
+
this.runGit(repoPath, ['config', 'user.name', identity.login]),
|
|
86
|
+
this.runGit(repoPath, ['config', 'user.email', identity.email]),
|
|
87
|
+
]).then(() => undefined);
|
|
88
|
+
}
|
|
89
|
+
async ensureRepo(repo) {
|
|
90
|
+
repo = assertRepoFullName(repo);
|
|
91
|
+
if (!this.gh.isAppAuth) {
|
|
92
|
+
throw new Error('GitHub App 认证未配置,无法托管工作区');
|
|
93
|
+
}
|
|
94
|
+
const token = await this.gh.ensureInstallationTokenForRepo(repo);
|
|
95
|
+
if (!token)
|
|
96
|
+
throw new Error(`无法获取 ${repo} 的 Installation Token`);
|
|
97
|
+
const repoPath = this.getRepoPath(repo);
|
|
98
|
+
fs.mkdirSync(path.dirname(repoPath), { recursive: true });
|
|
99
|
+
const identity = await this.gh.getBotIdentity();
|
|
100
|
+
if (!identity)
|
|
101
|
+
throw new Error('无法解析 GitHub App Bot 身份');
|
|
102
|
+
const cloneUrl = this.gh.buildCloneUrl(repo, token);
|
|
103
|
+
if (!fs.existsSync(path.join(repoPath, '.git'))) {
|
|
104
|
+
await this.runGit(path.dirname(repoPath), ['clone', cloneUrl, path.basename(repoPath)]);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
await this.runGit(repoPath, ['remote', 'set-url', 'origin', cloneUrl]);
|
|
108
|
+
await this.runGit(repoPath, ['fetch', 'origin', '--prune']);
|
|
109
|
+
}
|
|
110
|
+
await this.configureBotGit(repoPath, identity);
|
|
111
|
+
return repoPath;
|
|
112
|
+
}
|
|
113
|
+
async checkoutBranch(repo, branch, baseRef) {
|
|
114
|
+
repo = assertRepoFullName(repo);
|
|
115
|
+
branch = assertGitRefName(branch);
|
|
116
|
+
baseRef = assertGitRefName(baseRef);
|
|
117
|
+
const repoPath = await this.ensureRepo(repo);
|
|
118
|
+
const localBranches = await this.runGit(repoPath, ['branch', '--list', branch]);
|
|
119
|
+
if (localBranches.trim()) {
|
|
120
|
+
await this.runGit(repoPath, ['checkout', branch]);
|
|
121
|
+
await this.runGit(repoPath, ['pull', '--rebase', 'origin', branch]).catch(async () => {
|
|
122
|
+
await this.runGit(repoPath, ['fetch', 'origin', branch]);
|
|
123
|
+
});
|
|
124
|
+
return repoPath;
|
|
125
|
+
}
|
|
126
|
+
const remoteBranch = await this.runGit(repoPath, ['ls-remote', '--heads', 'origin', branch]);
|
|
127
|
+
if (remoteBranch.trim()) {
|
|
128
|
+
await this.runGit(repoPath, ['checkout', '-B', branch, `origin/${branch}`]);
|
|
129
|
+
return repoPath;
|
|
130
|
+
}
|
|
131
|
+
await this.runGit(repoPath, ['fetch', 'origin', baseRef]);
|
|
132
|
+
await this.runGit(repoPath, ['checkout', '-B', branch, `origin/${baseRef}`]);
|
|
133
|
+
return repoPath;
|
|
134
|
+
}
|
|
135
|
+
async commitAndPush(repo, branch, message) {
|
|
136
|
+
repo = assertRepoFullName(repo);
|
|
137
|
+
branch = assertGitRefName(branch);
|
|
138
|
+
const repoPath = this.getRepoPath(repo);
|
|
139
|
+
if (!fs.existsSync(repoPath)) {
|
|
140
|
+
throw new Error(`工作区不存在: ${repoPath},请先 github_prepare_workspace`);
|
|
141
|
+
}
|
|
142
|
+
const token = await this.gh.ensureInstallationTokenForRepo(repo);
|
|
143
|
+
if (!token)
|
|
144
|
+
throw new Error('Installation Token 不可用');
|
|
145
|
+
await this.runGit(repoPath, ['remote', 'set-url', 'origin', this.gh.buildCloneUrl(repo, token)]);
|
|
146
|
+
const status = await this.runGit(repoPath, ['status', '--porcelain']);
|
|
147
|
+
if (!status.trim())
|
|
148
|
+
return '没有可提交的变更';
|
|
149
|
+
await this.runGit(repoPath, ['add', '-A']);
|
|
150
|
+
await this.runGit(repoPath, ['commit', '-m', message]);
|
|
151
|
+
await this.runGit(repoPath, ['push', '-u', 'origin', branch]);
|
|
152
|
+
return `已 push 到 origin/${branch}`;
|
|
153
|
+
}
|
|
154
|
+
}
|