dsh-github-flow 0.1.4 → 0.1.5
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/README.en.md +27 -2
- package/README.md +26 -2
- package/docs/images/1.png +0 -0
- package/docs/images/2.png +0 -0
- package/lib/api/routes.d.ts +6 -1
- package/lib/api/routes.js +147 -94
- package/lib/cache.d.ts +21 -0
- package/lib/cache.js +48 -0
- package/lib/client.js +23 -5
- package/lib/commands/gh.js +3 -1
- package/lib/executor.d.ts +30 -1
- package/lib/executor.js +167 -50
- package/lib/github-service.d.ts +26 -0
- package/lib/github-service.js +1 -0
- package/lib/main.d.ts +2 -0
- package/lib/main.js +9 -1
- package/lib/tools/api.d.ts +4 -0
- package/lib/tools/api.js +23 -4
- package/lib/tools/pr.d.ts +12 -0
- package/lib/tools/pr.js +30 -3
- package/lib/types.d.ts +4 -0
- package/lib/workspace.d.ts +43 -0
- package/lib/workspace.js +136 -0
- package/package.json +2 -1
package/lib/executor.js
CHANGED
|
@@ -2,24 +2,119 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import
|
|
5
|
+
import { findMostRecentWorkspacePath, findWorkspacePathBySession, hasGitDir } from './workspace.js';
|
|
6
6
|
const execFileAsync = promisify(execFile);
|
|
7
|
+
/** 认证状态缓存时长。用户手动 `gh auth login/logout` 后最多 30 秒内面板才会反映,属可接受代价 */
|
|
8
|
+
const AUTH_CACHE_TTL_MS = 30_000;
|
|
9
|
+
/**
|
|
10
|
+
* 按码点安全截取字符串,等价于 String.prototype.slice 但对孤立的代理对(surrogate pair)做占位替换。
|
|
11
|
+
* 为什么必须这样截:本文件末尾会按 UTF-8 字节数截断,而 node:child_process 把 stdout 按字节缓冲区
|
|
12
|
+
* 切分后再 toString('utf8'),一个汉字被从中间切开时会变成 U+FFFD 替换字符。直接用 slice 会把半截
|
|
13
|
+
* 代理对留在结果里,导致下游 JSON.stringify 产出孤立转义序列、写入文件时编码报错。
|
|
14
|
+
*/
|
|
15
|
+
function safeSlice(input, start, end) {
|
|
16
|
+
const chars = Array.from(input);
|
|
17
|
+
const from = Math.max(0, start);
|
|
18
|
+
const to = end === undefined ? chars.length : Math.max(from, end);
|
|
19
|
+
return chars.slice(from, to).map((c) => (c.length === 1 && c.charCodeAt(0) >= 0xd800 && c.charCodeAt(0) <= 0xdfff ? '\ufffd' : c)).join('');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* 以 UTF-8 字节数(而非字符串长度)为口径,从两端保留内容。
|
|
23
|
+
* 为什么需要:maxOutputChars 数的是字符,中文一个字占 3 字节。只按字符限长时,
|
|
24
|
+
* 中文环境下真实放行的字节量可达标称值的数倍,Token 预算保护会失真。
|
|
25
|
+
* 采用二分查找定位截断点,避免对超大输出逐字符累加造成 O(n) 反复编码。
|
|
26
|
+
*/
|
|
27
|
+
function cutByUtf8Bytes(input, headBytes, tailBytes) {
|
|
28
|
+
const encoder = new TextEncoder();
|
|
29
|
+
const totalBytes = encoder.encode(input).length;
|
|
30
|
+
if (totalBytes <= headBytes + tailBytes) {
|
|
31
|
+
return { head: input, tail: '', keptHeadBytes: totalBytes, keptTailBytes: 0, truncated: false };
|
|
32
|
+
}
|
|
33
|
+
const chars = Array.from(input);
|
|
34
|
+
const atByte = (limit, fromEnd) => {
|
|
35
|
+
let lo = 0;
|
|
36
|
+
let hi = chars.length;
|
|
37
|
+
let bytes = 0;
|
|
38
|
+
while (lo < hi) {
|
|
39
|
+
const mid = (lo + hi + 1) >> 1;
|
|
40
|
+
const slice = fromEnd ? chars.slice(chars.length - mid) : chars.slice(0, mid);
|
|
41
|
+
const size = encoder.encode(slice.join('')).length;
|
|
42
|
+
if (size <= limit) {
|
|
43
|
+
lo = mid;
|
|
44
|
+
bytes = size;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
hi = mid - 1;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { count: lo, bytes };
|
|
51
|
+
};
|
|
52
|
+
const headPart = atByte(headBytes, false);
|
|
53
|
+
const tailPart = atByte(tailBytes, true);
|
|
54
|
+
return {
|
|
55
|
+
head: safeSlice(input, 0, headPart.count),
|
|
56
|
+
tail: safeSlice(input, chars.length - tailPart.count),
|
|
57
|
+
keptHeadBytes: headPart.bytes,
|
|
58
|
+
keptTailBytes: tailPart.bytes,
|
|
59
|
+
truncated: true,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 按「字符数 + UTF-8 字节数」双口径截断输出。
|
|
64
|
+
* 抽成纯函数是刻意的:截断逻辑是崩溃高发区(切碎多字节字符、误伤 JSON、把二进制流截成垃圾),
|
|
65
|
+
* 但通过真实 gh 命令很难稳定复现超限输出,抽出来才能用单元测试覆盖边界。
|
|
66
|
+
*/
|
|
67
|
+
export function truncateOutput(input, maxChars, maxBytes) {
|
|
68
|
+
// 双重口径限长:字符数守住 Token 预算,字节数守住真实传输量。
|
|
69
|
+
// 中文场景下两者会分别触发,因此两条路径的提示文案必须区分,避免用户按错误的配置项去调参。
|
|
70
|
+
if (input.length > maxChars) {
|
|
71
|
+
// 保留前 1/3 字符和尾部 1/2 字符,保留开头上下文和错误尾部
|
|
72
|
+
const headLen = Math.floor(maxChars * 0.35);
|
|
73
|
+
const tailLen = Math.floor(maxChars * 0.55);
|
|
74
|
+
const head = safeSlice(input, 0, headLen);
|
|
75
|
+
const tail = safeSlice(input, input.length - tailLen);
|
|
76
|
+
return {
|
|
77
|
+
text: `${head}\n\n... [⚠️ 提示:内容过长已由 DSH GitHub 插件截断中间部分,当前截取前 ${Math.round(headLen / 1024)}KB 与后 ${Math.round(tailLen / 1024)}KB] ...\n\n${tail}`,
|
|
78
|
+
truncated: true,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const byteCut = cutByUtf8Bytes(input, Math.floor(maxBytes * 0.35), Math.floor(maxBytes * 0.55));
|
|
82
|
+
if (byteCut.truncated) {
|
|
83
|
+
// 这里必须用「实际保留的字节数」而非 maxChars 推导值:字节口径触发时 maxChars 往往远大于
|
|
84
|
+
// 字节上限,早先版本照抄字符分支的算法,会报出与实际严重不符的「已保留 100KB」(实际仅数百字节)。
|
|
85
|
+
return {
|
|
86
|
+
text: `${byteCut.head}\n\n... [⚠️ 提示:内容超出 UTF-8 字节上限 (${maxBytes} bytes) 已截断中间部分,实际保留前 ${byteCut.keptHeadBytes} 字节与后 ${byteCut.keptTailBytes} 字节;如需放宽请调大配置项 maxOutputBytes] ...\n\n${byteCut.tail}`,
|
|
87
|
+
truncated: true,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { text: input, truncated: false };
|
|
91
|
+
}
|
|
7
92
|
export class GhExecutor {
|
|
8
93
|
ghPath;
|
|
9
94
|
defaultTimeoutMs;
|
|
10
95
|
maxOutputChars;
|
|
96
|
+
/** UTF-8 字节口径的硬上限;未配置时按 maxOutputChars × 4 推导(覆盖 CJK 3 字节与 emoji 4 字节) */
|
|
97
|
+
maxOutputBytes;
|
|
98
|
+
/** 认证状态缓存。`gh auth status` 实测 5.4 秒,而它位于全局驾驶舱关键路径的最前面 */
|
|
99
|
+
authCache = null;
|
|
11
100
|
constructor(config = {}) {
|
|
12
101
|
this.ghPath = config.ghPath || 'gh';
|
|
13
102
|
this.defaultTimeoutMs = config.defaultTimeoutMs || 30_000;
|
|
14
103
|
this.maxOutputChars = config.maxOutputChars || 24_000;
|
|
104
|
+
this.maxOutputBytes = config.maxOutputBytes || this.maxOutputChars * 4;
|
|
15
105
|
}
|
|
16
106
|
updateConfig(config = {}) {
|
|
17
107
|
if (config.ghPath)
|
|
18
108
|
this.ghPath = config.ghPath;
|
|
19
109
|
if (config.defaultTimeoutMs)
|
|
20
110
|
this.defaultTimeoutMs = config.defaultTimeoutMs;
|
|
21
|
-
if (config.maxOutputChars)
|
|
111
|
+
if (config.maxOutputChars) {
|
|
22
112
|
this.maxOutputChars = config.maxOutputChars;
|
|
113
|
+
if (!config.maxOutputBytes)
|
|
114
|
+
this.maxOutputBytes = config.maxOutputChars * 4;
|
|
115
|
+
}
|
|
116
|
+
if (config.maxOutputBytes)
|
|
117
|
+
this.maxOutputBytes = config.maxOutputBytes;
|
|
23
118
|
}
|
|
24
119
|
/**
|
|
25
120
|
* 智能解析最可靠的工作区物理路径:
|
|
@@ -29,45 +124,20 @@ export class GhExecutor {
|
|
|
29
124
|
* 4. 回退到 process.cwd()
|
|
30
125
|
*/
|
|
31
126
|
resolveWorkspaceCwd(explicitCwd) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
catch { }
|
|
127
|
+
// 显式路径优先,但只认真正含 .git 的目录:调用方传进来的可能是会话根目录而非仓库目录,
|
|
128
|
+
// 此时继续往下走按会话/最近活跃反查,比盲信传入值更准。
|
|
129
|
+
if (explicitCwd && hasGitDir(explicitCwd)) {
|
|
130
|
+
return explicitCwd;
|
|
39
131
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
// A. 优先通过 DSH 会话 ID 精准匹配物理工作区
|
|
46
|
-
const sessionId = process.env.DSH_SESSION_ID;
|
|
47
|
-
if (sessionId) {
|
|
48
|
-
for (const wsId of Object.keys(workspaces)) {
|
|
49
|
-
const item = workspaces[wsId];
|
|
50
|
-
if (item.sessionIds && Array.isArray(item.sessionIds) && item.sessionIds.includes(sessionId)) {
|
|
51
|
-
if (item.path && fs.existsSync(item.path)) {
|
|
52
|
-
return item.path;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
// B. 兜底匹配最近活跃更新的工作区
|
|
58
|
-
let latestItem = null;
|
|
59
|
-
for (const wsId of Object.keys(workspaces)) {
|
|
60
|
-
const item = workspaces[wsId];
|
|
61
|
-
if (!latestItem || new Date(item.updatedAt || 0) > new Date(latestItem.updatedAt || 0)) {
|
|
62
|
-
latestItem = item;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
if (latestItem && latestItem.path && fs.existsSync(latestItem.path)) {
|
|
66
|
-
return latestItem.path;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
132
|
+
const sessionId = process.env.DSH_SESSION_ID;
|
|
133
|
+
if (sessionId) {
|
|
134
|
+
const bySession = findWorkspacePathBySession(sessionId);
|
|
135
|
+
if (bySession)
|
|
136
|
+
return bySession;
|
|
69
137
|
}
|
|
70
|
-
|
|
138
|
+
const recent = findMostRecentWorkspacePath();
|
|
139
|
+
if (recent)
|
|
140
|
+
return recent;
|
|
71
141
|
return explicitCwd || process.cwd();
|
|
72
142
|
}
|
|
73
143
|
/**
|
|
@@ -93,16 +163,9 @@ export class GhExecutor {
|
|
|
93
163
|
if (!rawOutput && stderr) {
|
|
94
164
|
rawOutput = stderr.trim();
|
|
95
165
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const headLen = Math.floor(this.maxOutputChars * 0.35);
|
|
100
|
-
const tailLen = Math.floor(this.maxOutputChars * 0.55);
|
|
101
|
-
const head = rawOutput.slice(0, headLen);
|
|
102
|
-
const tail = rawOutput.slice(-tailLen);
|
|
103
|
-
rawOutput = `${head}\n\n... [⚠️ 提示:内容过长已由 DSH GitHub 插件截断中间部分,当前截取前 ${Math.round(headLen / 1024)}KB 与后 ${Math.round(tailLen / 1024)}KB] ...\n\n${tail}`;
|
|
104
|
-
truncated = true;
|
|
105
|
-
}
|
|
166
|
+
const outcome = truncateOutput(rawOutput, this.maxOutputChars, this.maxOutputBytes);
|
|
167
|
+
rawOutput = outcome.text;
|
|
168
|
+
const truncated = outcome.truncated;
|
|
106
169
|
// 如果未被截断且命令带有 --json 或包含 api,尝试解析为 JSON
|
|
107
170
|
if (!options.rawText && !truncated && (args.includes('--json') || args.includes('api'))) {
|
|
108
171
|
try {
|
|
@@ -141,8 +204,26 @@ export class GhExecutor {
|
|
|
141
204
|
}
|
|
142
205
|
/**
|
|
143
206
|
* 检查宿主机当前 GitHub CLI 的认证状态与账号信息
|
|
207
|
+
*
|
|
208
|
+
* @param cwd 解析工作区用的路径
|
|
209
|
+
* @param bypassCache 跳过进程内缓存。`/gh status` 这类人类显式发起的诊断必须看到实时结果,
|
|
210
|
+
* 而 Web 面板每次挂载都会来问一次,重复执行实测要 5.4 秒,属于纯浪费。
|
|
144
211
|
*/
|
|
145
|
-
async checkAuth(cwd) {
|
|
212
|
+
async checkAuth(cwd, bypassCache = false) {
|
|
213
|
+
const now = Date.now();
|
|
214
|
+
if (!bypassCache && this.authCache && now - this.authCache.at < AUTH_CACHE_TTL_MS) {
|
|
215
|
+
return this.authCache.value;
|
|
216
|
+
}
|
|
217
|
+
const value = await this.probeAuth(cwd);
|
|
218
|
+
// 只缓存确定的结果:出错时(未安装/未登录/网络异常)不缓存,
|
|
219
|
+
// 否则用户 `gh auth login` 之后还要再等满一个 TTL 才能看到面板恢复。
|
|
220
|
+
if (value.loggedIn) {
|
|
221
|
+
this.authCache = { at: now, value };
|
|
222
|
+
}
|
|
223
|
+
return value;
|
|
224
|
+
}
|
|
225
|
+
/** 实际执行 `gh auth status` 并解析结果,不含缓存逻辑 */
|
|
226
|
+
async probeAuth(cwd) {
|
|
146
227
|
const res = await this.run(['auth', 'status'], { cwd, timeoutMs: 10_000, rawText: true });
|
|
147
228
|
const raw = (res.rawOutput || '') + '\n' + (res.error || '');
|
|
148
229
|
const loggedIn = raw.includes('Logged in to') || raw.includes('✓');
|
|
@@ -191,6 +272,42 @@ export class GhExecutor {
|
|
|
191
272
|
raw,
|
|
192
273
|
};
|
|
193
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* 仅从本地 .git/config 解析仓库身份,绝不发起网络请求。
|
|
277
|
+
*
|
|
278
|
+
* 为什么需要它:全局驾驶舱要为每个工作区认识别仓库,而 getRepoMetadata 会调用
|
|
279
|
+
* `gh repo view` 走网络。实测 5 个工作区时该端点冷启动 20.5 秒,其中绝大部分耗在这 5 次
|
|
280
|
+
* 网络往返上——而有界并发根本救不了它,因为瓶颈是网络延迟而不是进程排队。
|
|
281
|
+
* 工作区矩阵只需要「这个目录属于哪个仓库」这一身份信息,本地 .git/config 已经完整具备,
|
|
282
|
+
* 描述/star 数等富字段是当前仓库详情才需要的。这一步把每个工作区从 1 次网络往返降为 0。
|
|
283
|
+
*/
|
|
284
|
+
getRepoIdentityFromLocal(cwd) {
|
|
285
|
+
const targetCwd = cwd || this.resolveWorkspaceCwd();
|
|
286
|
+
try {
|
|
287
|
+
const gitConfigPath = path.join(targetCwd, '.git', 'config');
|
|
288
|
+
if (!fs.existsSync(gitConfigPath))
|
|
289
|
+
return null;
|
|
290
|
+
const configText = fs.readFileSync(gitConfigPath, 'utf8');
|
|
291
|
+
const match = configText.match(/github\.com[:/]([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git)?(?:\s|\r|\n|$)/i);
|
|
292
|
+
if (!match)
|
|
293
|
+
return null;
|
|
294
|
+
const owner = match[1];
|
|
295
|
+
const repoName = match[2];
|
|
296
|
+
const nameWithOwner = `${owner}/${repoName}`;
|
|
297
|
+
return {
|
|
298
|
+
nameWithOwner,
|
|
299
|
+
name: repoName,
|
|
300
|
+
owner,
|
|
301
|
+
defaultBranch: 'main',
|
|
302
|
+
description: '',
|
|
303
|
+
isPrivate: false,
|
|
304
|
+
url: `https://github.com/${nameWithOwner}`,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
194
311
|
/**
|
|
195
312
|
* 获取当前目录(工作区)关联的远程仓库元数据
|
|
196
313
|
* 采用 gh repo view + 本地 git remote 双保险机制,彻底杜绝已配置远程却误判未关联的问题
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ctx.github` 服务的 TypeScript 声明合并。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要这个文件:README 教生态插件这样复用本插件的能力——
|
|
5
|
+
* export const inject = ['github']
|
|
6
|
+
* export function apply(ctx) { await ctx.github.checkAuth() }
|
|
7
|
+
* 但在此之前项目里没有任何 `declare module` 声明合并,消费方拿到的 `ctx.github`
|
|
8
|
+
* 类型是 unknown,照文档写会直接编译不过,也没有任何自动补全。
|
|
9
|
+
* 官方每个服务包(如 @deepseek-ai/dsh-workspace、dsh-commands)都做这件事,这里补齐。
|
|
10
|
+
*
|
|
11
|
+
* 消费方用法:import type {} from 'dsh-github-flow/host' —— 或直接 import 本插件导出的任一名,
|
|
12
|
+
* 只要类型被加载,`ctx.github` 就有完整类型。
|
|
13
|
+
*/
|
|
14
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
15
|
+
import type { GhExecutor } from './executor.js';
|
|
16
|
+
declare module '@deepseek-ai/cordis' {
|
|
17
|
+
interface Context {
|
|
18
|
+
/** 由 dsh-github-flow 提供的 GitHub CLI 执行引擎 */
|
|
19
|
+
github: GhExecutor;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 供消费方做类型引用的空导出。
|
|
24
|
+
* 目的是让 `import type {} from 'dsh-github-flow'` 能顺带加载上面的声明合并。
|
|
25
|
+
*/
|
|
26
|
+
export type { Context };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/lib/main.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { GhExecutor } from './executor.js';
|
|
2
|
+
import './github-service.js';
|
|
2
3
|
import type { PluginConfig } from './types.js';
|
|
3
4
|
export declare const name = "dsh-github-flow";
|
|
4
5
|
export declare const inject: readonly ['tools', 'commands', 'webServer', 'workspaceRegistry'];
|
|
@@ -18,3 +19,4 @@ export declare const Config: {
|
|
|
18
19
|
export declare function apply(ctx: any, config?: PluginConfig): void;
|
|
19
20
|
export type GitHubService = GhExecutor;
|
|
20
21
|
export * from './types.js';
|
|
22
|
+
export * from './github-service.js';
|
package/lib/main.js
CHANGED
|
@@ -6,6 +6,9 @@ import { createRepoTool } from './tools/repo.js';
|
|
|
6
6
|
import { createApiTool } from './tools/api.js';
|
|
7
7
|
import { registerGhCommand } from './commands/gh.js';
|
|
8
8
|
import { registerApiRoutes } from './api/routes.js';
|
|
9
|
+
// 引用即生效:加载本模块以挂上 declare module '@deepseek-ai/cordis' 的 ctx.github 类型声明,
|
|
10
|
+
// 并随下面 `export * from './github-service.js'` 一起传给消费方。
|
|
11
|
+
import './github-service.js';
|
|
9
12
|
export const name = 'dsh-github-flow';
|
|
10
13
|
export const inject = ['tools', 'commands', 'webServer', 'workspaceRegistry'];
|
|
11
14
|
/**
|
|
@@ -22,6 +25,7 @@ export const Config = {
|
|
|
22
25
|
ghPath: typeof cfg.ghPath === 'string' ? cfg.ghPath : 'gh',
|
|
23
26
|
defaultTimeoutMs: typeof cfg.defaultTimeoutMs === 'number' ? cfg.defaultTimeoutMs : 30_000,
|
|
24
27
|
maxOutputChars: typeof cfg.maxOutputChars === 'number' ? cfg.maxOutputChars : 24_000,
|
|
28
|
+
maxOutputBytes: typeof cfg.maxOutputBytes === 'number' ? cfg.maxOutputBytes : undefined,
|
|
25
29
|
cacheTtlMs: typeof cfg.cacheTtlMs === 'number' ? cfg.cacheTtlMs : 15_000,
|
|
26
30
|
defaultListLimit: typeof cfg.defaultListLimit === 'number' ? cfg.defaultListLimit : 20,
|
|
27
31
|
}
|
|
@@ -52,7 +56,11 @@ export function apply(ctx, config = {}) {
|
|
|
52
56
|
}
|
|
53
57
|
// 4. 注册面向 Client 端的 WebServer HTTP 路由(如果当前环境启用了 Web UI 服务)
|
|
54
58
|
if (ctx.webServer) {
|
|
55
|
-
|
|
59
|
+
// 传入 ctx 作用域对象,使 routes 能够通过 ctx.effect 绑定生命周期并在插件卸载/重载时自动注销;
|
|
60
|
+
// cacheTtlMs 传给路由层以尊重用户的缓存 TTL 配置。
|
|
61
|
+
registerApiRoutes(ctx, executor, ctx.workspaceRegistry, config.cacheTtlMs || 15_000);
|
|
56
62
|
}
|
|
57
63
|
}
|
|
58
64
|
export * from './types.js';
|
|
65
|
+
// 让 `ctx.github` 的类型声明随包一起被消费方加载(声明合并在 './github-service.js' 内)
|
|
66
|
+
export * from './github-service.js';
|
package/lib/tools/api.d.ts
CHANGED
package/lib/tools/api.js
CHANGED
|
@@ -10,7 +10,7 @@ function normalizeOutput(data) {
|
|
|
10
10
|
export function createApiTool(executor) {
|
|
11
11
|
return {
|
|
12
12
|
name: 'github_api',
|
|
13
|
-
description: '通过 GitHub CLI 认证环境直接调用任意 GitHub REST 或 GraphQL API 端点(兜底逃生门)。支持 --jq
|
|
13
|
+
description: '通过 GitHub CLI 认证环境直接调用任意 GitHub REST 或 GraphQL API 端点(兜底逃生门)。支持 --jq 过滤;fields 一律按字面字符串传参,避免 gh 的 -F 类型转换改写请求体。',
|
|
14
14
|
parameters: {
|
|
15
15
|
type: 'object',
|
|
16
16
|
properties: {
|
|
@@ -29,7 +29,11 @@ export function createApiTool(executor) {
|
|
|
29
29
|
},
|
|
30
30
|
fields: {
|
|
31
31
|
type: 'object',
|
|
32
|
-
description: '
|
|
32
|
+
description: '请求参数或请求体键值对。所有值按字面字符串(gh -f)发送,不做布尔/数字类型转换;嵌套对象请先在端点里用 JSON 字段或改用 raw 模式。',
|
|
33
|
+
},
|
|
34
|
+
raw: {
|
|
35
|
+
type: 'boolean',
|
|
36
|
+
description: '是否以原始响应体模式调用(附加 Accept: application/vnd.github.raw 并强制纯文本返回)。默认 false。适用于读取仓库文件内容,避免拿到 base64 编码的一整行。',
|
|
33
37
|
},
|
|
34
38
|
},
|
|
35
39
|
required: ['endpoint'],
|
|
@@ -57,12 +61,27 @@ export function createApiTool(executor) {
|
|
|
57
61
|
if (args.jq) {
|
|
58
62
|
cmd.push('--jq', args.jq);
|
|
59
63
|
}
|
|
64
|
+
if (args.raw === true) {
|
|
65
|
+
// 让 GitHub 直接返回文件字节而不是 {"content":"<base64>"} 包装。
|
|
66
|
+
// 没有这个出口时,读任何仓库文件都只能拿到一整行 base64——既不可读,
|
|
67
|
+
// 又会在超过截断阈值时被从中间切断而彻底无法还原。
|
|
68
|
+
cmd.push('-H', 'Accept: application/vnd.github.raw');
|
|
69
|
+
}
|
|
60
70
|
if (args.fields && typeof args.fields === 'object') {
|
|
61
71
|
for (const [k, v] of Object.entries(args.fields)) {
|
|
62
|
-
|
|
72
|
+
if (v === undefined || v === null)
|
|
73
|
+
continue;
|
|
74
|
+
if (typeof v === 'object') {
|
|
75
|
+
// 不把嵌套结构 JSON.stringify 后塞进 -F:gh 侧解析结果不可预期,且会静默产出错误请求体。
|
|
76
|
+
// 宁可显式报错,让调用方改用 raw 模式或把结构放进端点自身的 JSON 字段。
|
|
77
|
+
throw new Error(`github_api 的字段 "${k}" 是嵌套结构,无法作为表单参数发送。请改用 raw 模式传原始 JSON 请求体,或先把该结构序列化后作为字符串字段传入。`);
|
|
78
|
+
}
|
|
79
|
+
// 用 -f(字面字符串)而非 -F:-F 会做 "true"→布尔、"1"→数字的类型转换,
|
|
80
|
+
// 导致字符串请求体被悄悄改写,是跨 fork 提 PR 等场景失败的常见根因。
|
|
81
|
+
cmd.push('-f', `${k}=${String(v)}`);
|
|
63
82
|
}
|
|
64
83
|
}
|
|
65
|
-
const res = await executor.run(cmd, { cwd });
|
|
84
|
+
const res = await executor.run(cmd, { cwd, rawText: args.raw === true });
|
|
66
85
|
if (!res.ok)
|
|
67
86
|
throw new Error(res.error);
|
|
68
87
|
return normalizeOutput(res.data !== undefined ? res.data : res.rawOutput);
|
package/lib/tools/pr.d.ts
CHANGED
|
@@ -30,6 +30,10 @@ export declare function createPrTool(executor: GhExecutor): {
|
|
|
30
30
|
type: string;
|
|
31
31
|
description: string;
|
|
32
32
|
};
|
|
33
|
+
head: {
|
|
34
|
+
type: string;
|
|
35
|
+
description: string;
|
|
36
|
+
};
|
|
33
37
|
draft: {
|
|
34
38
|
type: string;
|
|
35
39
|
description: string;
|
|
@@ -44,6 +48,14 @@ export declare function createPrTool(executor: GhExecutor): {
|
|
|
44
48
|
enum: string[];
|
|
45
49
|
description: string;
|
|
46
50
|
};
|
|
51
|
+
delete_branch: {
|
|
52
|
+
type: string;
|
|
53
|
+
description: string;
|
|
54
|
+
};
|
|
55
|
+
dry_run: {
|
|
56
|
+
type: string;
|
|
57
|
+
description: string;
|
|
58
|
+
};
|
|
47
59
|
};
|
|
48
60
|
required: string[];
|
|
49
61
|
};
|
package/lib/tools/pr.js
CHANGED
|
@@ -39,6 +39,10 @@ export function createPrTool(executor) {
|
|
|
39
39
|
type: 'string',
|
|
40
40
|
description: '目标基准分支(默认主分支,如 main)。',
|
|
41
41
|
},
|
|
42
|
+
head: {
|
|
43
|
+
type: 'string',
|
|
44
|
+
description: '源分支,跨 fork 提 PR 时必填,格式 forkOwner:branch(如 ItQianChen:fix-bug);同仓库分支可直接写分支名。',
|
|
45
|
+
},
|
|
42
46
|
draft: {
|
|
43
47
|
type: 'boolean',
|
|
44
48
|
description: '是否创建为草稿 PR。',
|
|
@@ -53,6 +57,14 @@ export function createPrTool(executor) {
|
|
|
53
57
|
enum: ['merge', 'squash', 'rebase'],
|
|
54
58
|
description: '合并策略(merge 操作时使用,默认 squash)。',
|
|
55
59
|
},
|
|
60
|
+
delete_branch: {
|
|
61
|
+
type: 'boolean',
|
|
62
|
+
description: '合并后是否删除源分支(merge 操作时使用,默认 false)。删除分支不可撤销,只在确认该分支已无用后再开启。',
|
|
63
|
+
},
|
|
64
|
+
dry_run: {
|
|
65
|
+
type: 'boolean',
|
|
66
|
+
description: '是否只预演不真正创建(create 操作时使用,默认 false)。透传 gh --dry-run:打印将要创建的 PR 详情而不落库,用于提交前确认 head/base/title 是否正确。',
|
|
67
|
+
},
|
|
56
68
|
},
|
|
57
69
|
required: ['action'],
|
|
58
70
|
},
|
|
@@ -112,12 +124,22 @@ export function createPrTool(executor) {
|
|
|
112
124
|
const createCmd = ['pr', 'create', ...repoArgs, '--title', args.title || '', '--body', args.body || ''];
|
|
113
125
|
if (args.base)
|
|
114
126
|
createCmd.push('--base', args.base);
|
|
127
|
+
// 跨 fork 提 PR 必须显式给出 --head(形如 forkOwner:branch)。
|
|
128
|
+
// 为什么不能省:当 -R 指向的仓库不是当前 origin 时,gh 无法推断拿哪个分支开 PR,
|
|
129
|
+
// 会逐级回退去问本地 git,最终报出与真实原因无关的 "not a git repository"。
|
|
130
|
+
if (args.head)
|
|
131
|
+
createCmd.push('--head', args.head);
|
|
115
132
|
if (args.draft)
|
|
116
133
|
createCmd.push('--draft');
|
|
134
|
+
const dryRun = args.dry_run === true;
|
|
135
|
+
if (dryRun)
|
|
136
|
+
createCmd.push('--dry-run');
|
|
117
137
|
const res = await executor.run(createCmd, { cwd });
|
|
118
138
|
if (!res.ok)
|
|
119
139
|
throw new Error(res.error);
|
|
120
|
-
return
|
|
140
|
+
return dryRun
|
|
141
|
+
? { message: 'PR 预演完成(未真正创建)', detail: res.rawOutput }
|
|
142
|
+
: { message: 'PR 创建成功', url: res.rawOutput };
|
|
121
143
|
}
|
|
122
144
|
case 'review': {
|
|
123
145
|
if (!args.pr_number)
|
|
@@ -140,10 +162,15 @@ export function createPrTool(executor) {
|
|
|
140
162
|
if (!args.pr_number)
|
|
141
163
|
throw new Error('merge 操作必须提供 pr_number');
|
|
142
164
|
const method = args.merge_method === 'merge' ? '--merge' : (args.merge_method === 'rebase' ? '--rebase' : '--squash');
|
|
143
|
-
const
|
|
165
|
+
const mergeCmd = ['pr', 'merge', String(args.pr_number), ...repoArgs, method];
|
|
166
|
+
// 删分支默认关闭:合并他人仓库的 PR 时删掉源分支是不可撤销的破坏性动作,
|
|
167
|
+
// 而 gh 默认不删。替用户做掉它违背「可撤销的直接做、不可撤销的先问」。
|
|
168
|
+
if (args.delete_branch === true)
|
|
169
|
+
mergeCmd.push('--delete-branch');
|
|
170
|
+
const res = await executor.run(mergeCmd, { cwd });
|
|
144
171
|
if (!res.ok)
|
|
145
172
|
throw new Error(res.error);
|
|
146
|
-
return { message: 'PR
|
|
173
|
+
return { message: 'PR 合并完成', output: res.rawOutput };
|
|
147
174
|
}
|
|
148
175
|
default:
|
|
149
176
|
throw new Error(`未知的 PR 操作: ${args.action}`);
|
package/lib/types.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface PluginConfig {
|
|
|
8
8
|
defaultTimeoutMs?: number;
|
|
9
9
|
/** 单次安全输出字符数上限(防止 Token 溢出),默认 24,000 */
|
|
10
10
|
maxOutputChars?: number;
|
|
11
|
+
/** 单次安全输出 UTF-8 字节数硬上限,默认 maxOutputChars × 4(防止中文按字符计数时实际放行字节数倍增) */
|
|
12
|
+
maxOutputBytes?: number;
|
|
11
13
|
/** Web API 概览数据缓存时长(毫秒),默认 15,000ms */
|
|
12
14
|
cacheTtlMs?: number;
|
|
13
15
|
/** 查询列表默认限制条数,默认 20 */
|
|
@@ -151,4 +153,6 @@ export interface GlobalOverviewData {
|
|
|
151
153
|
}>;
|
|
152
154
|
lastUpdated: string;
|
|
153
155
|
error?: string;
|
|
156
|
+
/** 工作区矩阵读取失败的原因。区分「没有工作区」与「读不到工作区」,避免把故障显示成空状态 */
|
|
157
|
+
workspaceError?: string;
|
|
154
158
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export interface WorkspaceRecord {
|
|
2
|
+
id: string;
|
|
3
|
+
title: string;
|
|
4
|
+
path: string;
|
|
5
|
+
sessionIds: string[];
|
|
6
|
+
updatedAt: string;
|
|
7
|
+
}
|
|
8
|
+
/** 仅供测试与配置变更后失效使用 */
|
|
9
|
+
export declare function resetWorkspaceCache(): void;
|
|
10
|
+
export declare function workspaceJsonPath(): string;
|
|
11
|
+
/**
|
|
12
|
+
* 读取全部工作区记录。
|
|
13
|
+
* 返回空数组表示「文件不存在」,抛错表示「文件存在但无法解析」——调用方必须区分这两种情况,
|
|
14
|
+
* 否则 workspace.json 损坏时会显示成「你没有工作区」,把故障伪装成正常空状态。
|
|
15
|
+
*/
|
|
16
|
+
export declare function readWorkspaces(options?: {
|
|
17
|
+
bypassCache?: boolean;
|
|
18
|
+
}): WorkspaceRecord[];
|
|
19
|
+
/** 容错读取:读取失败时返回空数组并把原因交给调用方,绝不静默吞掉 */
|
|
20
|
+
export declare function tryReadWorkspaces(): {
|
|
21
|
+
workspaces: WorkspaceRecord[];
|
|
22
|
+
error?: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* 按会话 ID 反查物理工作区路径。
|
|
26
|
+
* 命中要求路径真实存在——workspace.json 里可能残留已删除的目录。
|
|
27
|
+
*/
|
|
28
|
+
export declare function findWorkspacePathBySession(sessionId: string): string | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* 匹配最近活跃的工作区路径。
|
|
31
|
+
* 注意 updatedAt 可能缺失或非 ISO 格式,统一用时间戳比较,无效值视为 0 而非 NaN——
|
|
32
|
+
* 后者会让所有比较都返回 false,导致永远选不中任何工作区。
|
|
33
|
+
*/
|
|
34
|
+
export declare function findMostRecentWorkspacePath(): string | undefined;
|
|
35
|
+
export declare function hasGitDir(dir: string): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* 有界并发 map。
|
|
38
|
+
* 为什么必须有:每个工作区探测最多 spawn 2 个 gh 进程,而 Node 的 libuv 线程池默认只有 4 个
|
|
39
|
+
* 线程可用。工作区一多,无上限的 Promise.all 会把进程全部排进队列,单次 spawn 超时 10s × 排队
|
|
40
|
+
* 造成整体秒级到十几秒的延迟(实测 /api/github/global-overview 曾达 18.9s)。
|
|
41
|
+
* 这里显式限制在途数量,让延迟可预期,而不是随工作区数量线性劣化。
|
|
42
|
+
*/
|
|
43
|
+
export declare function mapLimit<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
|