@zhin.js/adapter-github 1.1.25 → 1.1.27
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 +102 -0
- package/LICENSE +21 -0
- package/README.md +4 -4
- package/agents/github/messages.js +20 -0
- package/agents/github/messages.ts +20 -0
- package/agents/github/skills/github-account/handlers.js +67 -22
- package/agents/github/skills/github-account/handlers.ts +68 -28
- package/lib/console-spec.js +36 -3
- package/lib/gh-client.d.ts +27 -7
- package/lib/gh-client.js +71 -25
- package/lib/protocol.d.ts +1 -7
- package/lib/protocol.js +1 -23
- package/lib/webhook.d.ts +1 -2
- package/lib/webhook.js +10 -23
- package/package.json +30 -28
- package/schema.json +0 -56
- package/src/console-spec.ts +36 -3
- package/src/gh-client.ts +86 -31
- package/src/protocol.ts +2 -28
- package/src/webhook.ts +10 -27
package/lib/gh-client.js
CHANGED
|
@@ -17,6 +17,8 @@ export class GhClient {
|
|
|
17
17
|
apiHost;
|
|
18
18
|
executionSignal;
|
|
19
19
|
bindExecutionSignal(signal) { this.executionSignal = signal; }
|
|
20
|
+
/** Aborted when the owning endpoint stops; long-running background work should observe it. */
|
|
21
|
+
get lifecycleSignal() { return this.executionSignal; }
|
|
20
22
|
token;
|
|
21
23
|
_appAuth;
|
|
22
24
|
_resolvedKey;
|
|
@@ -582,40 +584,84 @@ export class GhClient {
|
|
|
582
584
|
return res.json();
|
|
583
585
|
}
|
|
584
586
|
/**
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
+
* 第二步:轮询等待用户授权。
|
|
588
|
+
* 可通过 `signal` 取消;单次请求有超时;网络/JSON 等瞬时错误会重试到设备码过期为止。
|
|
587
589
|
*/
|
|
588
|
-
static async deviceFlowPollToken(clientId, deviceCode, interval = 5, expiresIn = 900, host) {
|
|
590
|
+
static async deviceFlowPollToken({ clientId, deviceCode, interval = 5, expiresIn = 900, host, signal, requestTimeoutMs = 15_000, }) {
|
|
589
591
|
const baseUrl = host ? `https://${host}` : 'https://github.com';
|
|
590
592
|
const deadline = Date.now() + expiresIn * 1000;
|
|
591
593
|
let pollInterval = interval * 1000;
|
|
592
|
-
while (
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
594
|
+
while (true) {
|
|
595
|
+
if (signal?.aborted)
|
|
596
|
+
return { status: 'aborted' };
|
|
597
|
+
const remaining = deadline - Date.now();
|
|
598
|
+
if (remaining <= 0)
|
|
599
|
+
return { status: 'expired' };
|
|
600
|
+
if (!await abortableDelay(Math.min(pollInterval, remaining), signal))
|
|
601
|
+
return { status: 'aborted' };
|
|
602
|
+
if (Date.now() >= deadline)
|
|
603
|
+
return { status: 'expired' };
|
|
604
|
+
let data;
|
|
605
|
+
try {
|
|
606
|
+
const timeout = AbortSignal.timeout(requestTimeoutMs);
|
|
607
|
+
const res = await fetch(`${baseUrl}/login/oauth/access_token`, {
|
|
608
|
+
method: 'POST',
|
|
609
|
+
headers: {
|
|
610
|
+
'Content-Type': 'application/json',
|
|
611
|
+
'Accept': 'application/json',
|
|
612
|
+
},
|
|
613
|
+
body: JSON.stringify({
|
|
614
|
+
client_id: clientId,
|
|
615
|
+
device_code: deviceCode,
|
|
616
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
617
|
+
}),
|
|
618
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
619
|
+
});
|
|
620
|
+
data = await res.json();
|
|
609
621
|
}
|
|
610
|
-
|
|
611
|
-
|
|
622
|
+
catch {
|
|
623
|
+
if (signal?.aborted)
|
|
624
|
+
return { status: 'aborted' };
|
|
625
|
+
// Transient network / timeout / non-JSON response: keep polling until the deadline.
|
|
612
626
|
continue;
|
|
613
627
|
}
|
|
628
|
+
if (!data || typeof data !== 'object')
|
|
629
|
+
continue;
|
|
630
|
+
if (typeof data.access_token === 'string' && data.access_token) {
|
|
631
|
+
return {
|
|
632
|
+
status: 'authorized',
|
|
633
|
+
access_token: data.access_token,
|
|
634
|
+
token_type: typeof data.token_type === 'string' ? data.token_type : 'bearer',
|
|
635
|
+
scope: typeof data.scope === 'string' ? data.scope : '',
|
|
636
|
+
};
|
|
637
|
+
}
|
|
614
638
|
if (data.error === 'authorization_pending')
|
|
615
639
|
continue;
|
|
616
|
-
|
|
617
|
-
|
|
640
|
+
if (data.error === 'slow_down') {
|
|
641
|
+
const next = Number(data.interval);
|
|
642
|
+
pollInterval = (Number.isFinite(next) && next > 0 ? next : pollInterval / 1000 + 5) * 1000;
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
if (data.error === 'expired_token')
|
|
646
|
+
return { status: 'expired' };
|
|
647
|
+
if (data.error === 'access_denied')
|
|
648
|
+
return { status: 'denied' };
|
|
649
|
+
if (typeof data.error === 'string') {
|
|
650
|
+
return { status: 'error', error: typeof data.error_description === 'string' ? data.error_description : data.error };
|
|
651
|
+
}
|
|
652
|
+
// No token and no error (e.g. a 5xx JSON body): treat as transient.
|
|
618
653
|
}
|
|
619
|
-
return null;
|
|
620
654
|
}
|
|
621
655
|
}
|
|
656
|
+
/** Resolves true after `ms`, or false as soon as `signal` aborts. */
|
|
657
|
+
function abortableDelay(ms, signal) {
|
|
658
|
+
return new Promise((resolve) => {
|
|
659
|
+
if (signal?.aborted) {
|
|
660
|
+
resolve(false);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const onAbort = () => { clearTimeout(timer); resolve(false); };
|
|
664
|
+
const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(true); }, ms);
|
|
665
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
666
|
+
});
|
|
667
|
+
}
|
package/lib/protocol.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { EndpointProfileConfiguration } from 'zhin.js/adapter';
|
|
2
|
-
import type { IncomingMessage } from 'node:http';
|
|
3
2
|
import { type ConversationRef } from '@zhin.js/im-contract';
|
|
4
3
|
import { type GenericWebhookPayload, type IssueCommentPayload, type PRReviewCommentPayload, type PRReviewPayload } from './types.js';
|
|
5
4
|
export type { EventType, GenericWebhookPayload, GitHubComment, GitHubIssue, GitHubOAuthUser, GitHubPR, GitHubRepo, GitHubUser, IssueCommentPayload, ParsedChannel, PRReviewCommentPayload, PRReviewPayload, Subscription, } from './types.js';
|
|
@@ -57,13 +56,8 @@ export declare function formatInboundContent(body: string): string;
|
|
|
57
56
|
/** Build markdown body for Issue/PR comment send. */
|
|
58
57
|
export declare function formatOutboundBody(payload: unknown): string;
|
|
59
58
|
export declare function verifyWebhookSignature(secret: string, rawBody: string, signatureHeader: string | undefined): boolean;
|
|
60
|
-
export declare function headerValue(headers:
|
|
61
|
-
export declare function readTextBody(request: IncomingMessage, options?: {
|
|
62
|
-
readonly limit?: number;
|
|
63
|
-
}): Promise<string>;
|
|
59
|
+
export declare function headerValue(headers: Headers, name: string): string;
|
|
64
60
|
export declare function parseIssueCommentInbound(payload: IssueCommentPayload): GithubInboundComment | null;
|
|
65
61
|
export declare function parsePrReviewCommentInbound(payload: PRReviewCommentPayload): GithubInboundComment | null;
|
|
66
62
|
export declare function parsePrReviewInbound(payload: PRReviewPayload): GithubInboundComment | null;
|
|
67
63
|
export declare function formatNotification(event: string, p: GenericWebhookPayload): string;
|
|
68
|
-
/** Parse @mentions in markdown into a flat display string (gateway text path). */
|
|
69
|
-
export declare function parseMarkdownMentions(md: string): string;
|
package/lib/protocol.js
CHANGED
|
@@ -158,25 +158,7 @@ export function verifyWebhookSignature(secret, rawBody, signatureHeader) {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
export function headerValue(headers, name) {
|
|
161
|
-
|
|
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');
|
|
161
|
+
return headers.get(name) ?? '';
|
|
180
162
|
}
|
|
181
163
|
export function parseIssueCommentInbound(payload) {
|
|
182
164
|
if (payload.action !== 'created' || !payload.comment)
|
|
@@ -275,7 +257,3 @@ export function formatNotification(event, p) {
|
|
|
275
257
|
return `📬 ${repo}\n📡 ${event}${p.action ? ` (${p.action})` : ''} by ${sender}\n🔗 ${repoUrl}`;
|
|
276
258
|
}
|
|
277
259
|
}
|
|
278
|
-
/** Parse @mentions in markdown into a flat display string (gateway text path). */
|
|
279
|
-
export function parseMarkdownMentions(md) {
|
|
280
|
-
return md;
|
|
281
|
-
}
|
package/lib/webhook.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* GitHub webhook HTTP: verify → parse → admit inbound comments.
|
|
3
3
|
*/
|
|
4
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
5
4
|
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
6
5
|
import { type GithubInboundComment, type ResolvedGithubConfig } from './protocol.js';
|
|
7
6
|
export interface GithubWebhookHandler {
|
|
@@ -10,5 +9,5 @@ export interface GithubWebhookHandler {
|
|
|
10
9
|
admit(comment: GithubInboundComment, nativeEvent?: unknown): void;
|
|
11
10
|
}
|
|
12
11
|
export declare function registerGithubWebhookRoutes(http: HttpHost, handler: GithubWebhookHandler): HttpRouteRegistration[];
|
|
13
|
-
export declare function handleGithubWebhookRequest(request:
|
|
12
|
+
export declare function handleGithubWebhookRequest(request: Request, handler: GithubWebhookHandler): Promise<Response>;
|
|
14
13
|
export declare function dispatchGithubWebhookPayload(event: string, payload: unknown, handler: GithubWebhookHandler): Promise<void>;
|
package/lib/webhook.js
CHANGED
|
@@ -1,28 +1,23 @@
|
|
|
1
|
+
import { readTextBody } from '@zhin.js/host-http-contract';
|
|
1
2
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
2
|
-
import { headerValue, parseIssueCommentInbound, parsePrReviewCommentInbound, parsePrReviewInbound,
|
|
3
|
+
import { headerValue, parseIssueCommentInbound, parsePrReviewCommentInbound, parsePrReviewInbound, verifyWebhookSignature, } from './protocol.js';
|
|
3
4
|
const logger = getLogger('github');
|
|
4
5
|
export function registerGithubWebhookRoutes(http, handler) {
|
|
5
6
|
const routePath = handler.config.webhookPath;
|
|
6
7
|
return [
|
|
7
|
-
http.route('POST', routePath,
|
|
8
|
-
await handleGithubWebhookRequest(request, response, handler);
|
|
9
|
-
}, { summary: 'GitHub webhook', tags: ['github'] }),
|
|
8
|
+
http.route('POST', routePath, (request) => handleGithubWebhookRequest(request, handler), { summary: 'GitHub webhook', tags: ['github'] }),
|
|
10
9
|
];
|
|
11
10
|
}
|
|
12
|
-
export async function handleGithubWebhookRequest(request,
|
|
11
|
+
export async function handleGithubWebhookRequest(request, handler) {
|
|
13
12
|
const secret = handler.config.webhookSecret;
|
|
14
13
|
if (!secret) {
|
|
15
|
-
|
|
16
|
-
response.end(JSON.stringify({ error: 'Webhook not configured' }));
|
|
17
|
-
return;
|
|
14
|
+
return new Response(JSON.stringify({ error: 'Webhook not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' } });
|
|
18
15
|
}
|
|
19
16
|
const signature = headerValue(request.headers, 'x-hub-signature-256');
|
|
20
17
|
const event = headerValue(request.headers, 'x-github-event');
|
|
21
18
|
const deliveryId = headerValue(request.headers, 'x-github-delivery');
|
|
22
19
|
if (!signature || !event) {
|
|
23
|
-
|
|
24
|
-
response.end(JSON.stringify({ error: 'Missing signature or event header' }));
|
|
25
|
-
return;
|
|
20
|
+
return new Response(JSON.stringify({ error: 'Missing signature or event header' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
26
21
|
}
|
|
27
22
|
try {
|
|
28
23
|
const rawBody = await readTextBody(request);
|
|
@@ -33,31 +28,23 @@ export async function handleGithubWebhookRequest(request, response, handler) {
|
|
|
33
28
|
error: 'invalid signature',
|
|
34
29
|
delivery: deliveryId,
|
|
35
30
|
}));
|
|
36
|
-
|
|
37
|
-
response.end(JSON.stringify({ error: 'Invalid signature' }));
|
|
38
|
-
return;
|
|
31
|
+
return new Response(JSON.stringify({ error: 'Invalid signature' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
|
39
32
|
}
|
|
40
33
|
let payload;
|
|
41
34
|
try {
|
|
42
35
|
payload = rawBody ? JSON.parse(rawBody) : {};
|
|
43
36
|
}
|
|
44
37
|
catch {
|
|
45
|
-
|
|
46
|
-
response.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
47
|
-
return;
|
|
38
|
+
return new Response(JSON.stringify({ error: 'Invalid JSON' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
48
39
|
}
|
|
49
|
-
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
50
|
-
response.end(JSON.stringify({ ok: true }));
|
|
51
40
|
void dispatchGithubWebhookPayload(event, payload, handler).catch((e) => {
|
|
52
41
|
logger.error(`Webhook 事件处理失败 (${event}):`, e);
|
|
53
42
|
});
|
|
43
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
54
44
|
}
|
|
55
45
|
catch (error) {
|
|
56
46
|
logger.error('Webhook error:', error);
|
|
57
|
-
|
|
58
|
-
response.writeHead(500, { 'Content-Type': 'application/json' });
|
|
59
|
-
response.end(JSON.stringify({ error: 'Internal Server Error' }));
|
|
60
|
-
}
|
|
47
|
+
return new Response(JSON.stringify({ error: 'Internal Server Error' }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
61
48
|
}
|
|
62
49
|
}
|
|
63
50
|
export async function dispatchGithubWebhookPayload(event, payload, handler) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-github",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.27",
|
|
4
4
|
"description": "Zhin.js GitHub adapter for Plugin Runtime (App auth + webhook)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -39,27 +39,28 @@
|
|
|
39
39
|
"directory": "plugins/adapters/github"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"
|
|
43
|
-
"@zhin.js/
|
|
44
|
-
"@zhin.js/
|
|
45
|
-
"@zhin.js/
|
|
46
|
-
"@zhin.js/
|
|
47
|
-
"@zhin.js/
|
|
48
|
-
"@zhin.js/
|
|
49
|
-
"@zhin.js/
|
|
50
|
-
"@zhin.js/
|
|
51
|
-
"@zhin.js/
|
|
52
|
-
"@zhin.js/
|
|
42
|
+
"zod": "^4.6.7",
|
|
43
|
+
"@zhin.js/adapter": "1.1.33",
|
|
44
|
+
"@zhin.js/agent-feature": "1.1.17",
|
|
45
|
+
"@zhin.js/console-contract": "1.1.6",
|
|
46
|
+
"@zhin.js/core": "1.2.0",
|
|
47
|
+
"@zhin.js/feature-kit": "1.1.16",
|
|
48
|
+
"@zhin.js/host-http": "1.2.0",
|
|
49
|
+
"@zhin.js/host-http-contract": "1.2.0",
|
|
50
|
+
"@zhin.js/im-contract": "1.1.14",
|
|
51
|
+
"@zhin.js/logger": "1.1.3",
|
|
52
|
+
"@zhin.js/page": "1.1.18",
|
|
53
|
+
"@zhin.js/skill": "1.1.17",
|
|
54
|
+
"@zhin.js/tool": "1.1.19"
|
|
53
55
|
},
|
|
54
56
|
"peerDependencies": {
|
|
55
|
-
"
|
|
56
|
-
"@zhin.js/
|
|
57
|
-
"@zhin.js/
|
|
58
|
-
"@zhin.js/
|
|
59
|
-
"@zhin.js/
|
|
60
|
-
"@zhin.js/
|
|
61
|
-
"
|
|
62
|
-
"zhin.js": "^1.1.24"
|
|
57
|
+
"@zhin.js/adapter": "^1.1.33",
|
|
58
|
+
"@zhin.js/agent": "^1.2.0",
|
|
59
|
+
"@zhin.js/command": "^1.2.0",
|
|
60
|
+
"@zhin.js/core": "^1.2.0",
|
|
61
|
+
"@zhin.js/host-http": "^1.2.0",
|
|
62
|
+
"@zhin.js/prompt-section": "^1.1.16",
|
|
63
|
+
"zhin.js": "^1.1.26"
|
|
63
64
|
},
|
|
64
65
|
"peerDependenciesMeta": {
|
|
65
66
|
"@zhin.js/agent": {
|
|
@@ -73,19 +74,15 @@
|
|
|
73
74
|
},
|
|
74
75
|
"zhin.js": {
|
|
75
76
|
"optional": true
|
|
76
|
-
},
|
|
77
|
-
"zod": {
|
|
78
|
-
"optional": true
|
|
79
77
|
}
|
|
80
78
|
},
|
|
81
79
|
"devDependencies": {
|
|
82
80
|
"@types/node": "^26.1.2",
|
|
83
81
|
"typescript": "^6.0.3",
|
|
84
82
|
"vitest": "^4.1.10",
|
|
85
|
-
"
|
|
86
|
-
"@zhin.js/
|
|
87
|
-
"
|
|
88
|
-
"zhin.js": "1.1.24"
|
|
83
|
+
"@zhin.js/agent": "1.2.0",
|
|
84
|
+
"@zhin.js/prompt-section": "1.1.16",
|
|
85
|
+
"zhin.js": "1.1.26"
|
|
89
86
|
},
|
|
90
87
|
"files": [
|
|
91
88
|
"adapters",
|
|
@@ -137,6 +134,11 @@
|
|
|
137
134
|
{
|
|
138
135
|
"package": "@zhin.js/page",
|
|
139
136
|
"api": "^1.0.0"
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"package": "@zhin.js/prompt-section",
|
|
140
|
+
"api": "^1.0.0",
|
|
141
|
+
"optional": true
|
|
140
142
|
}
|
|
141
143
|
],
|
|
142
144
|
"plugins": []
|
|
@@ -144,6 +146,6 @@
|
|
|
144
146
|
"scripts": {
|
|
145
147
|
"build": "tsc",
|
|
146
148
|
"clean": "rimraf lib",
|
|
147
|
-
"test": "
|
|
149
|
+
"test": "vitest run --root ../../.. plugins/adapters/github/tests"
|
|
148
150
|
}
|
|
149
151
|
}
|
package/schema.json
CHANGED
|
@@ -26,34 +26,6 @@
|
|
|
26
26
|
"type": "string",
|
|
27
27
|
"description": "Managed git workspace root"
|
|
28
28
|
},
|
|
29
|
-
"master": {
|
|
30
|
-
"type": [
|
|
31
|
-
"string",
|
|
32
|
-
"number"
|
|
33
|
-
],
|
|
34
|
-
"description": "框架 master(GitHub user login or id;AI/工具权限、endpoint 管理)。endpoints[i].master 可逐项覆盖",
|
|
35
|
-
"x-i18n": {
|
|
36
|
-
"en": {
|
|
37
|
-
"description": "Framework masters (GitHub user login or id; AI/tool permissions and endpoint management). endpoints[i].master can override this value."
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
},
|
|
41
|
-
"trusted": {
|
|
42
|
-
"type": "array",
|
|
43
|
-
"items": {
|
|
44
|
-
"type": [
|
|
45
|
-
"string",
|
|
46
|
-
"number"
|
|
47
|
-
],
|
|
48
|
-
"description": "Trusted GitHub user login or id"
|
|
49
|
-
},
|
|
50
|
-
"description": "框架 trusted 用户列表(弱于 master)。endpoints[i].trusted 可逐项追加",
|
|
51
|
-
"x-i18n": {
|
|
52
|
-
"en": {
|
|
53
|
-
"description": "Framework trusted users, below master authority. endpoints[i].trusted can add users."
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
},
|
|
57
29
|
"endpoints": {
|
|
58
30
|
"type": "array",
|
|
59
31
|
"minItems": 1,
|
|
@@ -62,34 +34,6 @@
|
|
|
62
34
|
"type": "object",
|
|
63
35
|
"additionalProperties": false,
|
|
64
36
|
"properties": {
|
|
65
|
-
"master": {
|
|
66
|
-
"type": [
|
|
67
|
-
"string",
|
|
68
|
-
"number"
|
|
69
|
-
],
|
|
70
|
-
"description": "本 endpoint 的框架 master(GitHub user login or id);覆盖顶层 master",
|
|
71
|
-
"x-i18n": {
|
|
72
|
-
"en": {
|
|
73
|
-
"description": "Framework masters for this endpoint (GitHub user login or id), overriding the top-level master list."
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
},
|
|
77
|
-
"trusted": {
|
|
78
|
-
"type": "array",
|
|
79
|
-
"items": {
|
|
80
|
-
"type": [
|
|
81
|
-
"string",
|
|
82
|
-
"number"
|
|
83
|
-
],
|
|
84
|
-
"description": "Trusted GitHub user login or id"
|
|
85
|
-
},
|
|
86
|
-
"description": "本 endpoint 的 trusted 列表",
|
|
87
|
-
"x-i18n": {
|
|
88
|
-
"en": {
|
|
89
|
-
"description": "Trusted users for this endpoint."
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
},
|
|
93
37
|
"app_id": {
|
|
94
38
|
"type": [
|
|
95
39
|
"string",
|
package/src/console-spec.ts
CHANGED
|
@@ -7,9 +7,42 @@ export const githubConsoleSpec = {
|
|
|
7
7
|
adapterKey: 'github',
|
|
8
8
|
adapterDisplayName: 'GitHub',
|
|
9
9
|
fields: [
|
|
10
|
-
{
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
{
|
|
11
|
+
key: 'app_id',
|
|
12
|
+
required: true,
|
|
13
|
+
env: true,
|
|
14
|
+
label: 'GitHub App ID',
|
|
15
|
+
labelI18n: { en: 'GitHub App ID' },
|
|
16
|
+
group: '应用凭证与私钥',
|
|
17
|
+
groupI18n: { en: 'App Credentials & Key' },
|
|
18
|
+
placeholder: '123456',
|
|
19
|
+
description: 'GitHub App 设置页中的 App ID',
|
|
20
|
+
descriptionI18n: { en: 'GitHub App ID' },
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
key: 'private_key',
|
|
24
|
+
required: true,
|
|
25
|
+
secret: true,
|
|
26
|
+
label: '私钥路径或 PEM 内容',
|
|
27
|
+
labelI18n: { en: 'Private Key Path or PEM' },
|
|
28
|
+
group: '应用凭证与私钥',
|
|
29
|
+
groupI18n: { en: 'App Credentials & Key' },
|
|
30
|
+
placeholder: './data/github-app.pem',
|
|
31
|
+
description: 'PEM 文件路径(推荐,如 ./data/xxx.pem)或 PEM 内容',
|
|
32
|
+
descriptionI18n: { en: 'PEM file path (recommended) or PEM content' },
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
key: 'webhook_secret',
|
|
36
|
+
env: true,
|
|
37
|
+
label: 'Webhook Secret (签名密钥)',
|
|
38
|
+
labelI18n: { en: 'Webhook Secret' },
|
|
39
|
+
group: 'Webhook 回调',
|
|
40
|
+
groupI18n: { en: 'Webhook Callback' },
|
|
41
|
+
placeholder: '留空则仅作为 API-only 出站模式运行',
|
|
42
|
+
placeholderI18n: { en: 'Optional webhook secret (API-only if omitted)' },
|
|
43
|
+
description: 'webhook 签名密钥(不配则 API-only)',
|
|
44
|
+
descriptionI18n: { en: 'Webhook signing secret (API-only when omitted)' },
|
|
45
|
+
},
|
|
13
46
|
] as readonly AdapterConsoleField[],
|
|
14
47
|
docsUrl: 'https://zhin.js.org/adapters/github',
|
|
15
48
|
readPermission: 'github:read',
|
package/src/gh-client.ts
CHANGED
|
@@ -67,6 +67,8 @@ export class GhClient {
|
|
|
67
67
|
protected apiHost?: string;
|
|
68
68
|
protected executionSignal?: AbortSignal;
|
|
69
69
|
protected bindExecutionSignal(signal: AbortSignal): void { this.executionSignal = signal; }
|
|
70
|
+
/** Aborted when the owning endpoint stops; long-running background work should observe it. */
|
|
71
|
+
get lifecycleSignal(): AbortSignal | undefined { return this.executionSignal; }
|
|
70
72
|
private token?: string;
|
|
71
73
|
private _appAuth?: AppAuth;
|
|
72
74
|
private _resolvedKey?: string;
|
|
@@ -691,46 +693,99 @@ export class GhClient {
|
|
|
691
693
|
}
|
|
692
694
|
|
|
693
695
|
/**
|
|
694
|
-
*
|
|
695
|
-
*
|
|
696
|
+
* 第二步:轮询等待用户授权。
|
|
697
|
+
* 可通过 `signal` 取消;单次请求有超时;网络/JSON 等瞬时错误会重试到设备码过期为止。
|
|
696
698
|
*/
|
|
697
|
-
static async deviceFlowPollToken(
|
|
698
|
-
clientId
|
|
699
|
-
deviceCode
|
|
700
|
-
interval
|
|
701
|
-
expiresIn
|
|
702
|
-
host
|
|
703
|
-
|
|
699
|
+
static async deviceFlowPollToken({
|
|
700
|
+
clientId,
|
|
701
|
+
deviceCode,
|
|
702
|
+
interval = 5,
|
|
703
|
+
expiresIn = 900,
|
|
704
|
+
host,
|
|
705
|
+
signal,
|
|
706
|
+
requestTimeoutMs = 15_000,
|
|
707
|
+
}: GhDeviceFlowPollRequest): Promise<GhDeviceFlowPollResult> {
|
|
704
708
|
const baseUrl = host ? `https://${host}` : 'https://github.com';
|
|
705
709
|
const deadline = Date.now() + expiresIn * 1000;
|
|
706
710
|
let pollInterval = interval * 1000;
|
|
707
711
|
|
|
708
|
-
while (
|
|
709
|
-
|
|
710
|
-
const
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
712
|
+
while (true) {
|
|
713
|
+
if (signal?.aborted) return { status: 'aborted' };
|
|
714
|
+
const remaining = deadline - Date.now();
|
|
715
|
+
if (remaining <= 0) return { status: 'expired' };
|
|
716
|
+
if (!await abortableDelay(Math.min(pollInterval, remaining), signal)) return { status: 'aborted' };
|
|
717
|
+
if (Date.now() >= deadline) return { status: 'expired' };
|
|
718
|
+
|
|
719
|
+
let data: Record<string, unknown>;
|
|
720
|
+
try {
|
|
721
|
+
const timeout = AbortSignal.timeout(requestTimeoutMs);
|
|
722
|
+
const res = await fetch(`${baseUrl}/login/oauth/access_token`, {
|
|
723
|
+
method: 'POST',
|
|
724
|
+
headers: {
|
|
725
|
+
'Content-Type': 'application/json',
|
|
726
|
+
'Accept': 'application/json',
|
|
727
|
+
},
|
|
728
|
+
body: JSON.stringify({
|
|
729
|
+
client_id: clientId,
|
|
730
|
+
device_code: deviceCode,
|
|
731
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
732
|
+
}),
|
|
733
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
734
|
+
});
|
|
735
|
+
data = await res.json() as Record<string, unknown>;
|
|
736
|
+
} catch {
|
|
737
|
+
if (signal?.aborted) return { status: 'aborted' };
|
|
738
|
+
// Transient network / timeout / non-JSON response: keep polling until the deadline.
|
|
739
|
+
continue;
|
|
725
740
|
}
|
|
741
|
+
if (!data || typeof data !== 'object') continue;
|
|
742
|
+
if (typeof data.access_token === 'string' && data.access_token) {
|
|
743
|
+
return {
|
|
744
|
+
status: 'authorized',
|
|
745
|
+
access_token: data.access_token,
|
|
746
|
+
token_type: typeof data.token_type === 'string' ? data.token_type : 'bearer',
|
|
747
|
+
scope: typeof data.scope === 'string' ? data.scope : '',
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
if (data.error === 'authorization_pending') continue;
|
|
726
751
|
if (data.error === 'slow_down') {
|
|
727
|
-
|
|
752
|
+
const next = Number(data.interval);
|
|
753
|
+
pollInterval = (Number.isFinite(next) && next > 0 ? next : pollInterval / 1000 + 5) * 1000;
|
|
728
754
|
continue;
|
|
729
755
|
}
|
|
730
|
-
if (data.error === '
|
|
731
|
-
|
|
732
|
-
|
|
756
|
+
if (data.error === 'expired_token') return { status: 'expired' };
|
|
757
|
+
if (data.error === 'access_denied') return { status: 'denied' };
|
|
758
|
+
if (typeof data.error === 'string') {
|
|
759
|
+
return { status: 'error', error: typeof data.error_description === 'string' ? data.error_description : data.error };
|
|
760
|
+
}
|
|
761
|
+
// No token and no error (e.g. a 5xx JSON body): treat as transient.
|
|
733
762
|
}
|
|
734
|
-
return null;
|
|
735
763
|
}
|
|
736
764
|
}
|
|
765
|
+
|
|
766
|
+
export interface GhDeviceFlowPollRequest {
|
|
767
|
+
readonly clientId: string;
|
|
768
|
+
readonly deviceCode: string;
|
|
769
|
+
/** Seconds between polls (GitHub default 5). */
|
|
770
|
+
readonly interval?: number;
|
|
771
|
+
/** Seconds until the device code expires (GitHub default 900). */
|
|
772
|
+
readonly expiresIn?: number;
|
|
773
|
+
readonly host?: string;
|
|
774
|
+
readonly signal?: AbortSignal;
|
|
775
|
+
readonly requestTimeoutMs?: number;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
export type GhDeviceFlowPollResult =
|
|
779
|
+
| { readonly status: 'authorized'; readonly access_token: string; readonly token_type: string; readonly scope: string }
|
|
780
|
+
| { readonly status: 'expired' | 'denied' | 'aborted' }
|
|
781
|
+
| { readonly status: 'error'; readonly error: string };
|
|
782
|
+
|
|
783
|
+
/** Resolves true after `ms`, or false as soon as `signal` aborts. */
|
|
784
|
+
function abortableDelay(ms: number, signal?: AbortSignal): Promise<boolean> {
|
|
785
|
+
return new Promise((resolve) => {
|
|
786
|
+
if (signal?.aborted) { resolve(false); return; }
|
|
787
|
+
const onAbort = () => { clearTimeout(timer); resolve(false); };
|
|
788
|
+
const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(true); }, ms);
|
|
789
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
790
|
+
});
|
|
791
|
+
}
|