dsh-github-flow 0.2.0 → 0.3.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/README.en.md +68 -22
- package/README.md +69 -23
- package/docs/UPGRADE-NOTES.md +93 -0
- package/docs/architecture.md +76 -31
- package/docs/images/1.png +0 -0
- package/docs/images/2.png +0 -0
- package/docs/images/3.png +0 -0
- package/docs/images/4.png +0 -0
- package/docs/npm-publish-guide.md +27 -5
- package/lib/api/routes-actions.d.ts +18 -4
- package/lib/api/routes-actions.js +143 -19
- package/lib/api/routes-lifecycle.d.ts +1 -1
- package/lib/api/routes-lifecycle.js +53 -2
- package/lib/api/routes-overview.js +104 -1
- package/lib/client.js +991 -55
- package/lib/commands/gh.js +11 -2
- package/lib/executor.d.ts +118 -1
- package/lib/executor.js +479 -12
- package/lib/main.js +10 -0
- package/lib/renderers/markdown.d.ts +12 -1
- package/lib/renderers/markdown.js +150 -16
- package/lib/tools/help.js +144 -11
- package/lib/tools/image-utils.d.ts +79 -0
- package/lib/tools/image-utils.js +247 -0
- package/lib/tools/issue.d.ts +18 -4
- package/lib/tools/issue.js +161 -6
- package/lib/tools/pr.d.ts +12 -4
- package/lib/tools/pr.js +128 -7
- package/lib/tools/repo.d.ts +20 -4
- package/lib/tools/repo.js +196 -4
- package/lib/tools/run.d.ts +8 -0
- package/lib/tools/run.js +104 -3
- package/lib/tools/secret.d.ts +5 -0
- package/lib/tools/secret.js +108 -23
- package/lib/types.d.ts +85 -1
- package/lib/update-settings.d.ts +18 -0
- package/lib/update-settings.js +60 -0
- package/lib/updater.d.ts +32 -1
- package/lib/updater.js +117 -9
- package/lib/upgrade-manager.d.ts +70 -1
- package/lib/upgrade-manager.js +346 -46
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +1 -1
package/lib/tools/pr.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { renderPrMarkdown } from '../renderers/markdown.js';
|
|
2
|
+
import { extractImageUrls, downloadImages, buildImageBlocks, filterRequestedUrls } from './image-utils.js';
|
|
2
3
|
function normalizeOutput(data) {
|
|
3
4
|
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
4
5
|
return data;
|
|
@@ -17,8 +18,8 @@ export function createPrTool(executor) {
|
|
|
17
18
|
properties: {
|
|
18
19
|
action: {
|
|
19
20
|
type: 'string',
|
|
20
|
-
enum: ['list', 'view', 'create', 'diff', 'checks', 'review', 'merge', 'checkout', 'status'],
|
|
21
|
-
description: 'PR
|
|
21
|
+
enum: ['list', 'view', 'create', 'diff', 'checks', 'review', 'merge', 'checkout', 'status', 'view_images', 'ready', 'revert', 'update_branch'],
|
|
22
|
+
description: 'PR 操作类型。view_images 下载正文与评论中的图片(UI 变更对比图、复现截图等)到本地并以图片内容块返回,供 Agent 审查时直接查看。merge/revert 需 confirm: true(revert 在远端创建反向提交与新 PR)。',
|
|
22
23
|
},
|
|
23
24
|
pr_number: {
|
|
24
25
|
type: 'number',
|
|
@@ -60,7 +61,11 @@ export function createPrTool(executor) {
|
|
|
60
61
|
},
|
|
61
62
|
delete_branch: {
|
|
62
63
|
type: 'boolean',
|
|
63
|
-
description: '
|
|
64
|
+
description: '合并/revert 后是否删除源分支(merge、revert 操作时使用,默认 false)。删除分支不可撤销,只在确认该分支已无用后再开启。',
|
|
65
|
+
},
|
|
66
|
+
undo: {
|
|
67
|
+
type: 'boolean',
|
|
68
|
+
description: 'ready 动作可选:反向操作,把 PR 转回 Draft(gh 原生 --undo)。',
|
|
64
69
|
},
|
|
65
70
|
state: {
|
|
66
71
|
type: 'string',
|
|
@@ -87,13 +92,18 @@ export function createPrTool(executor) {
|
|
|
87
92
|
type: 'boolean',
|
|
88
93
|
description: '是否只预演不真正创建(create 操作时使用,默认 false)。透传 gh --dry-run:打印将要创建的 PR 详情而不落库,用于提交前确认 head/base/title 是否正确。',
|
|
89
94
|
},
|
|
95
|
+
image_urls: {
|
|
96
|
+
type: 'array',
|
|
97
|
+
items: { type: 'string' },
|
|
98
|
+
description: 'view_images 可选:仅下载指定 URL 的图片(从 view 返回的 images 清单中选取,不在清单内的会被拒绝)。留空下载全部(正文+评论)。',
|
|
99
|
+
},
|
|
90
100
|
},
|
|
91
101
|
required: ['action'],
|
|
92
102
|
},
|
|
93
103
|
output: {
|
|
94
104
|
schema: { type: 'object' },
|
|
95
105
|
render(args, value) {
|
|
96
|
-
return [{ type: 'text', text: renderPrMarkdown(args, value) }];
|
|
106
|
+
return [{ type: 'text', text: renderPrMarkdown(args, value) }, ...buildImageBlocks(value)];
|
|
97
107
|
},
|
|
98
108
|
},
|
|
99
109
|
async execute(args, execContext) {
|
|
@@ -130,14 +140,82 @@ export function createPrTool(executor) {
|
|
|
130
140
|
}
|
|
131
141
|
case 'view': {
|
|
132
142
|
const target = args.pr_number ? [String(args.pr_number)] : [];
|
|
133
|
-
const res = await executor.run(['pr', 'view', ...target, ...repoArgs, '--json', 'number,title,body,state,author,labels,assignees,reviewDecision,mergeable,statusCheckRollup,url'], runOpts);
|
|
143
|
+
const res = await executor.run(['pr', 'view', ...target, ...repoArgs, '--json', 'number,title,body,state,author,labels,assignees,reviewDecision,mergeable,statusCheckRollup,url,comments'], runOpts);
|
|
134
144
|
if (!res.ok)
|
|
135
145
|
throw new Error(res.error);
|
|
136
|
-
|
|
146
|
+
const out = normalizeOutput(res.data || res.rawOutput);
|
|
147
|
+
// 附带图片 URL 清单(正文+评论,与 view_images 提取范围同源):
|
|
148
|
+
// Agent 按正文里的原始 URL 决策,需要看图时用 image_urls 精准选取
|
|
149
|
+
const bodies = [typeof out.body === 'string' ? out.body : ''];
|
|
150
|
+
if (Array.isArray(out.comments)) {
|
|
151
|
+
for (const c of out.comments) {
|
|
152
|
+
if (c && typeof c.body === 'string')
|
|
153
|
+
bodies.push(c.body);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
out.images = Array.from(new Set(bodies.flatMap((b) => extractImageUrls(b))));
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
case 'view_images': {
|
|
160
|
+
if (!args.pr_number)
|
|
161
|
+
throw new Error('view_images 操作必须提供 pr_number');
|
|
162
|
+
// PR 与 Issue 图片机制同构;评论走同一 issues-comments API(实测 PR 评论
|
|
163
|
+
// 的 <img> 形态与 issue 完全一致),正文 + 评论合并提取一次覆盖全部
|
|
164
|
+
const detail = await executor.run(['pr', 'view', String(args.pr_number), ...repoArgs, '--json', 'body,comments'], runOpts);
|
|
165
|
+
if (!detail.ok)
|
|
166
|
+
throw new Error(detail.error);
|
|
167
|
+
const d = detail.data && typeof detail.data === 'object' ? detail.data : {};
|
|
168
|
+
const bodies = [];
|
|
169
|
+
if (typeof d.body === 'string')
|
|
170
|
+
bodies.push(d.body);
|
|
171
|
+
if (Array.isArray(d.comments)) {
|
|
172
|
+
for (const c of d.comments) {
|
|
173
|
+
if (c && typeof c.body === 'string')
|
|
174
|
+
bodies.push(c.body);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// 多来源合并去重(Set 语义由 extractImageUrls 内部保证)
|
|
178
|
+
const extracted = Array.from(new Set(bodies.flatMap((b) => extractImageUrls(b))));
|
|
179
|
+
if (extracted.length === 0) {
|
|
180
|
+
return { message: `PR #${args.pr_number} 正文与评论中未发现图片附件。` };
|
|
181
|
+
}
|
|
182
|
+
// 目标选择:不传 image_urls 下载全部;传入则与清单严格交集
|
|
183
|
+
//(防幻觉/诱导的任意 URL 下载),空数组视为误传明确报错
|
|
184
|
+
let targets = extracted;
|
|
185
|
+
const rejections = [];
|
|
186
|
+
if (args.image_urls !== undefined) {
|
|
187
|
+
if (!Array.isArray(args.image_urls) || args.image_urls.length === 0) {
|
|
188
|
+
throw new Error('image_urls 为空数组:如需下载全部请省略该参数,如需指定请传入 view 返回清单中的 URL。');
|
|
189
|
+
}
|
|
190
|
+
const filtered = filterRequestedUrls(args.image_urls, extracted);
|
|
191
|
+
targets = filtered.matched;
|
|
192
|
+
for (const bad of filtered.rejected) {
|
|
193
|
+
rejections.push(`${String(bad).slice(-24)}: 不在本 PR 图片清单中,已拒绝(防任意下载)`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const { images, failures: dlFailures, remaining } = await downloadImages(targets, (url) => executor.run(['api', url], { ...runOpts, binary: true, timeoutMs: 30_000 }));
|
|
197
|
+
const failures = [...rejections, ...dlFailures];
|
|
198
|
+
if (images.length === 0 && rejections.length === 0 && !remaining) {
|
|
199
|
+
return { message: `PR #${args.pr_number} 的图片全部下载失败:`, failures };
|
|
200
|
+
}
|
|
201
|
+
if (images.length === 0) {
|
|
202
|
+
return { message: `PR #${args.pr_number} 未能获取任何图片(请求 ${targets.length} 张,被拒 ${rejections.length} 张)`, failures, remaining };
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
message: `PR #${args.pr_number} 成功获取 ${images.length} 张图片(正文+评论)${failures.length ? `(${failures.length} 条失败/提示)` : ''}:`,
|
|
206
|
+
images,
|
|
207
|
+
failures: failures.length ? failures : undefined,
|
|
208
|
+
// 续传清单:触顶时 Agent 直接 image_urls: remaining 发起下一批
|
|
209
|
+
remaining,
|
|
210
|
+
};
|
|
137
211
|
}
|
|
138
212
|
case 'checks': {
|
|
139
213
|
const target = args.pr_number ? [String(args.pr_number)] : [];
|
|
140
|
-
|
|
214
|
+
// 字段集为 gh pr checks 的合法九字段子集(v2.50.0 引入 --json 起从未变更)。
|
|
215
|
+
// 旧字段集的 conclusion/detailsUrl 是 REST API 字段名,pr checks 从未支持——
|
|
216
|
+
// 初版误搬导致该 action 在所有 gh 版本上必然报 Unknown JSON field(Issue #3)。
|
|
217
|
+
// bucket 是 state 的分类视图(pass/fail/pending/skipping/cancel),link 即详情页。
|
|
218
|
+
const res = await executor.run(['pr', 'checks', ...target, ...repoArgs, '--json', 'name,state,bucket,link,workflow,startedAt,completedAt'], runOpts);
|
|
141
219
|
if (!res.ok)
|
|
142
220
|
throw new Error(res.error);
|
|
143
221
|
return normalizeOutput(res.data || res.rawOutput);
|
|
@@ -229,6 +307,49 @@ export function createPrTool(executor) {
|
|
|
229
307
|
throw new Error(res.error);
|
|
230
308
|
return { message: 'PR 合并完成', output: res.rawOutput };
|
|
231
309
|
}
|
|
310
|
+
// ---- 变更面新增(S3)----
|
|
311
|
+
case 'ready': {
|
|
312
|
+
if (!args.pr_number)
|
|
313
|
+
throw new Error('ready 操作必须提供 pr_number');
|
|
314
|
+
// Draft→Ready;gh 原生支持 --undo 反向(转回 draft),完整可逆,免 confirm
|
|
315
|
+
const cmd = ['pr', 'ready', String(args.pr_number), ...repoArgs];
|
|
316
|
+
if (args.undo === true)
|
|
317
|
+
cmd.push('--undo');
|
|
318
|
+
const res = await executor.run(cmd, runOpts);
|
|
319
|
+
if (!res.ok)
|
|
320
|
+
throw new Error(res.error);
|
|
321
|
+
return { message: `PR #${args.pr_number} 已${args.undo === true ? '转回 Draft' : '从 Draft 转为 Ready(进入正式评审)'}` };
|
|
322
|
+
}
|
|
323
|
+
case 'revert': {
|
|
324
|
+
if (!args.pr_number)
|
|
325
|
+
throw new Error('revert 操作必须提供 pr_number');
|
|
326
|
+
// revert 在远端创建反向提交与新 PR——虽不碰原 PR,但产生新实体,按 merge 级 confirm(D5)
|
|
327
|
+
if (args.confirm !== true) {
|
|
328
|
+
return {
|
|
329
|
+
status: 'AWAITING_CONFIRMATION',
|
|
330
|
+
warning: `⚠️ 高危操作拦截:你即将对 PR #${args.pr_number} 创建反向提交(revert)。`,
|
|
331
|
+
impact: '将在远端创建反向提交并生成新的 revert PR——这是新增远端实体,不是本地操作。',
|
|
332
|
+
requiredAction: '如确认执行,请向用户明确确认并附加 `confirm: true` 重新调用。',
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
const revertCmd = ['pr', 'revert', String(args.pr_number), ...repoArgs];
|
|
336
|
+
if (args.delete_branch === true)
|
|
337
|
+
revertCmd.push('--delete-branch');
|
|
338
|
+
const res = await executor.run(revertCmd, runOpts);
|
|
339
|
+
if (!res.ok)
|
|
340
|
+
throw new Error(res.error);
|
|
341
|
+
return { message: `PR #${args.pr_number} 的反向提交已创建`, url: res.rawOutput };
|
|
342
|
+
}
|
|
343
|
+
case 'update_branch': {
|
|
344
|
+
if (!args.pr_number)
|
|
345
|
+
throw new Error('update_branch 操作必须提供 pr_number');
|
|
346
|
+
// 等价 repo sync 的单 PR 版(push merge commit 到 PR 分支);恰是「分支落后」
|
|
347
|
+
// 的自愈动作,confirm 摩擦会阻碍 Agent 自愈闭环(D6),输出同步摘要即可
|
|
348
|
+
const res = await executor.run(['pr', 'update-branch', String(args.pr_number), ...repoArgs], runOpts);
|
|
349
|
+
if (!res.ok)
|
|
350
|
+
throw new Error(res.error);
|
|
351
|
+
return { message: `PR #${args.pr_number} 分支已从基准分支同步更新`, output: res.rawOutput };
|
|
352
|
+
}
|
|
232
353
|
default:
|
|
233
354
|
throw new Error(`未知的 PR 操作: ${args.action}`);
|
|
234
355
|
}
|
package/lib/tools/repo.d.ts
CHANGED
|
@@ -14,10 +14,29 @@ export declare function createRepoTool(executor: GhExecutor): {
|
|
|
14
14
|
type: string;
|
|
15
15
|
description: string;
|
|
16
16
|
};
|
|
17
|
+
image_urls: {
|
|
18
|
+
type: string;
|
|
19
|
+
items: {
|
|
20
|
+
type: string;
|
|
21
|
+
};
|
|
22
|
+
description: string;
|
|
23
|
+
};
|
|
24
|
+
include_badges: {
|
|
25
|
+
type: string;
|
|
26
|
+
description: string;
|
|
27
|
+
};
|
|
17
28
|
query: {
|
|
18
29
|
type: string;
|
|
19
30
|
description: string;
|
|
20
31
|
};
|
|
32
|
+
path: {
|
|
33
|
+
type: string;
|
|
34
|
+
description: string;
|
|
35
|
+
};
|
|
36
|
+
ref: {
|
|
37
|
+
type: string;
|
|
38
|
+
description: string;
|
|
39
|
+
};
|
|
21
40
|
limit: {
|
|
22
41
|
type: string;
|
|
23
42
|
description: string;
|
|
@@ -41,10 +60,7 @@ export declare function createRepoTool(executor: GhExecutor): {
|
|
|
41
60
|
schema: {
|
|
42
61
|
type: string;
|
|
43
62
|
};
|
|
44
|
-
render(args: any, value: any):
|
|
45
|
-
type: string;
|
|
46
|
-
text: string;
|
|
47
|
-
}[];
|
|
63
|
+
render(args: any, value: any): any[];
|
|
48
64
|
};
|
|
49
65
|
execute(args: any, execContext: any): Promise<Record<string, any>>;
|
|
50
66
|
};
|
package/lib/tools/repo.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { renderRepoMarkdown } from '../renderers/markdown.js';
|
|
2
|
+
import { extractReadmeImageUrls, downloadImages, buildImageBlocks, filterRequestedUrls } from './image-utils.js';
|
|
2
3
|
function normalizeOutput(data) {
|
|
3
4
|
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
4
5
|
return data;
|
|
@@ -17,16 +18,33 @@ export function createRepoTool(executor) {
|
|
|
17
18
|
properties: {
|
|
18
19
|
action: {
|
|
19
20
|
type: 'string',
|
|
20
|
-
enum: ['view', 'search_code', 'search_repos', 'sync'],
|
|
21
|
-
description: '
|
|
21
|
+
enum: ['view', 'readme', 'search_code', 'search_repos', 'search_issues', 'search_prs', 'search_commits', 'read_file', 'read_dir', 'sync'],
|
|
22
|
+
description: '仓库操作类型。readme 读取 README 原文并附带图片清单(可选 image_urls 下载指定图片,不传下载全部截图;默认过滤徽章小图标)。read_file/read_dir 免 clone 读取任意路径文件与目录(gh ≥2.95,低版本自动降级 API 链路)。',
|
|
22
23
|
},
|
|
23
24
|
repo: {
|
|
24
25
|
type: 'string',
|
|
25
26
|
description: '目标仓库 [owner/repo],留空则识别当前仓库。',
|
|
26
27
|
},
|
|
28
|
+
image_urls: {
|
|
29
|
+
type: 'array',
|
|
30
|
+
items: { type: 'string' },
|
|
31
|
+
description: 'readme 可选:仅下载指定 URL 的图片(从 readme 返回的 images 清单中选取)。留空下载全部。',
|
|
32
|
+
},
|
|
33
|
+
include_badges: {
|
|
34
|
+
type: 'boolean',
|
|
35
|
+
description: 'readme 可选:保留徽章类小图标(shields.io 等,默认过滤)。需要检查徽章状态时开启。',
|
|
36
|
+
},
|
|
27
37
|
query: {
|
|
28
38
|
type: 'string',
|
|
29
|
-
description: '检索关键词(search_code
|
|
39
|
+
description: '检索关键词(search_* 动作使用)。限定符直接写进 query(gh 惯例):如 "repo:owner/name state:open label:bug"。注意 search_code/search_commits 必须包含搜索文本,纯限定符查询(如仅 "repo:x")会被 gh 拒绝。',
|
|
40
|
+
},
|
|
41
|
+
path: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
description: 'read_file 必填 / read_dir 可选(默认仓库根目录):目标文件或目录路径(如 "src/tools/pr.ts"、".github/workflows")。',
|
|
44
|
+
},
|
|
45
|
+
ref: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
description: 'read_file/read_dir 可选:从指定分支/tag/commit 读取(默认默认分支)。',
|
|
30
48
|
},
|
|
31
49
|
limit: {
|
|
32
50
|
type: 'number',
|
|
@@ -50,7 +68,7 @@ export function createRepoTool(executor) {
|
|
|
50
68
|
output: {
|
|
51
69
|
schema: { type: 'object' },
|
|
52
70
|
render(args, value) {
|
|
53
|
-
return [{ type: 'text', text: renderRepoMarkdown(args, value) }];
|
|
71
|
+
return [{ type: 'text', text: renderRepoMarkdown(args, value) }, ...buildImageBlocks(value)];
|
|
54
72
|
},
|
|
55
73
|
},
|
|
56
74
|
async execute(args, execContext) {
|
|
@@ -80,6 +98,64 @@ export function createRepoTool(executor) {
|
|
|
80
98
|
}
|
|
81
99
|
return normalizeOutput(res.data || res.rawOutput);
|
|
82
100
|
}
|
|
101
|
+
case 'readme': {
|
|
102
|
+
let repoTarget = args.repo;
|
|
103
|
+
if (!repoTarget) {
|
|
104
|
+
const meta = await executor.getRepoMetadata(cwd, sessionId);
|
|
105
|
+
if (meta?.nameWithOwner)
|
|
106
|
+
repoTarget = meta.nameWithOwner;
|
|
107
|
+
}
|
|
108
|
+
if (!repoTarget)
|
|
109
|
+
throw new Error('readme 操作必须提供 repo [owner/repo],或在关联 Git 远程的工作区运行');
|
|
110
|
+
const [owner, repoName] = repoTarget.split('/');
|
|
111
|
+
if (!owner || !repoName)
|
|
112
|
+
throw new Error(`readme 需要 [owner/repo] 形式的仓库名(实际: ${repoTarget})`);
|
|
113
|
+
// ① 取 README 原文(raw 头与 github_api 的 raw 模式同款技术,输出纯 Markdown)
|
|
114
|
+
const rawRes = await executor.run(['api', `repos/${owner}/${repoName}/readme`, '-H', 'Accept: application/vnd.github.raw'], { ...runOpts, rawText: true, timeoutMs: 20_000 });
|
|
115
|
+
if (!rawRes.ok)
|
|
116
|
+
throw new Error(rawRes.error);
|
|
117
|
+
const readmeText = rawRes.rawOutput || '';
|
|
118
|
+
// ② 默认分支:先本地 view 元数据拿(一次 API),失败兜底 main
|
|
119
|
+
let branch = 'main';
|
|
120
|
+
const metaRes = await executor.run(['repo', 'view', repoTarget, '--json', 'defaultBranchRef'], runOpts);
|
|
121
|
+
if (metaRes.ok && metaRes.data?.defaultBranchRef?.name) {
|
|
122
|
+
branch = metaRes.data.defaultBranchRef.name;
|
|
123
|
+
}
|
|
124
|
+
// ③ 提取图片:绝对 URL + 相对路径解析为 raw URL,默认滤徽章
|
|
125
|
+
const { urls: extracted, relativeResolved, badgeFiltered } = extractReadmeImageUrls(readmeText, {
|
|
126
|
+
owner, repo: repoName, branch, includeBadges: args.include_badges === true,
|
|
127
|
+
});
|
|
128
|
+
// 不带 image_urls:返回文本(截断保护)+ 清单,Agent 按需再取图
|
|
129
|
+
if (args.image_urls === undefined) {
|
|
130
|
+
return {
|
|
131
|
+
message: `已获取 ${repoTarget} 的 README(${readmeText.length} 字符${badgeFiltered ? `,过滤 ${badgeFiltered} 个徽章图标` : ''}${relativeResolved ? `,解析 ${relativeResolved} 个仓库内相对路径图片` : ''})`,
|
|
132
|
+
readme: readmeText.length > 6000 ? readmeText.slice(0, 6000) + '\n\n…(已截断,完整内容见仓库)' : readmeText,
|
|
133
|
+
images: extracted,
|
|
134
|
+
branch,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
// ④ 带 image_urls:交集校验 + 下载(语义与 issue/pr view_images 完全一致)
|
|
138
|
+
if (!Array.isArray(args.image_urls) || args.image_urls.length === 0) {
|
|
139
|
+
throw new Error('image_urls 为空数组:如需下载全部请省略该参数,如需指定请传入 readme 返回清单中的 URL。');
|
|
140
|
+
}
|
|
141
|
+
if (extracted.length === 0) {
|
|
142
|
+
return { message: `${repoTarget} 的 README 中未发现图片附件(徽章已过滤,可用 include_badges: true 保留)。` };
|
|
143
|
+
}
|
|
144
|
+
const filtered = filterRequestedUrls(args.image_urls, extracted);
|
|
145
|
+
const targets = filtered.matched;
|
|
146
|
+
const rejections = filtered.rejected.map((b) => `${String(b).slice(-24)}: 不在 README 图片清单中,已拒绝(防任意下载)`);
|
|
147
|
+
const { images, failures: dlFailures, remaining } = await downloadImages(targets, (url) => executor.run(['api', url], { ...runOpts, binary: true, timeoutMs: 30_000 }));
|
|
148
|
+
const failures = [...rejections, ...dlFailures];
|
|
149
|
+
if (images.length === 0) {
|
|
150
|
+
return { message: `${repoTarget} README 图片未能获取(请求 ${targets.length} 张,被拒 ${rejections.length} 张)`, failures, remaining };
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
message: `${repoTarget} README 成功获取 ${images.length} 张图片${failures.length ? `(${failures.length} 条失败/提示)` : ''}:`,
|
|
154
|
+
images,
|
|
155
|
+
failures: failures.length ? failures : undefined,
|
|
156
|
+
remaining,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
83
159
|
case 'search_code': {
|
|
84
160
|
if (!args.query)
|
|
85
161
|
throw new Error('search_code 必须提供 query');
|
|
@@ -97,6 +173,122 @@ export function createRepoTool(executor) {
|
|
|
97
173
|
throw new Error(res.error);
|
|
98
174
|
return normalizeOutput(res.data || res.rawOutput);
|
|
99
175
|
}
|
|
176
|
+
case 'search_issues':
|
|
177
|
+
case 'search_prs': {
|
|
178
|
+
if (!args.query)
|
|
179
|
+
throw new Error(`${args.action} 必须提供 query`);
|
|
180
|
+
// 限定符(repo:/state:/label: 等)直接写进 query 本体(gh search 惯例),
|
|
181
|
+
// 不做独立参数——参数爆炸 + 与 gh 手册心智模型分叉(D3 决策)。
|
|
182
|
+
// prs 比 issues 多 isDraft 字段(实测合法集 18 vs 17)。
|
|
183
|
+
const repoArgs = args.repo ? ['--repo', args.repo] : [];
|
|
184
|
+
const fields = args.action === 'search_prs'
|
|
185
|
+
? 'number,title,state,author,labels,isDraft,updatedAt,url'
|
|
186
|
+
: 'number,title,state,author,labels,updatedAt,url';
|
|
187
|
+
const res = await executor.run(['search', args.action === 'search_prs' ? 'prs' : 'issues', args.query, ...repoArgs, '--json', fields, '-L', limitStr], runOpts);
|
|
188
|
+
if (!res.ok)
|
|
189
|
+
throw new Error(res.error);
|
|
190
|
+
return normalizeOutput(res.data || res.rawOutput);
|
|
191
|
+
}
|
|
192
|
+
case 'search_commits': {
|
|
193
|
+
if (!args.query)
|
|
194
|
+
throw new Error('search_commits 必须提供 query');
|
|
195
|
+
const repoArgs = args.repo ? ['--repo', args.repo] : [];
|
|
196
|
+
const res = await executor.run(['search', 'commits', args.query, ...repoArgs, '--json', 'sha,commit,author,repository,url', '-L', limitStr], runOpts);
|
|
197
|
+
if (!res.ok)
|
|
198
|
+
throw new Error(res.error);
|
|
199
|
+
return normalizeOutput(res.data || res.rawOutput);
|
|
200
|
+
}
|
|
201
|
+
case 'read_file': {
|
|
202
|
+
if (!args.path)
|
|
203
|
+
throw new Error('read_file 必须提供 path(目标文件路径,如 "package.json")');
|
|
204
|
+
let repoTarget = args.repo;
|
|
205
|
+
if (!repoTarget) {
|
|
206
|
+
const meta = await executor.getRepoMetadata(cwd, sessionId);
|
|
207
|
+
if (meta?.nameWithOwner)
|
|
208
|
+
repoTarget = meta.nameWithOwner;
|
|
209
|
+
}
|
|
210
|
+
if (!repoTarget)
|
|
211
|
+
throw new Error('read_file 必须提供 repo [owner/repo],或在关联 Git 远程的工作区运行');
|
|
212
|
+
const [owner, repoName] = repoTarget.split('/');
|
|
213
|
+
if (!owner || !repoName)
|
|
214
|
+
throw new Error(`read_file 需要 [owner/repo] 形式的仓库名(实际: ${repoTarget})`);
|
|
215
|
+
// 主链路走管道直出(无 --json):gh 把原始内容写 stdout,executor 的
|
|
216
|
+
// 24KB 截断与 UTF-8 处理免费复用;--json 模式 content 是 base64,
|
|
217
|
+
// 中段截断即废且需解码 dance,弃用。
|
|
218
|
+
const refArgs = args.ref ? ['--ref', args.ref] : [];
|
|
219
|
+
const res = await executor.runGuarded(['repo', 'read-file', args.path, '-R', repoTarget, ...refArgs], {
|
|
220
|
+
...runOpts,
|
|
221
|
+
rawText: true,
|
|
222
|
+
timeoutMs: 20_000,
|
|
223
|
+
minVersion: '2.95.0',
|
|
224
|
+
capability: 'gh repo read-file',
|
|
225
|
+
// 降级链:contents 端点 raw Accept(readme 同款技术),与主链路输出同构。
|
|
226
|
+
// ref 必须走 URL query 而非 -f:gh api 在带 -f 时会把 GET 隐式改写为
|
|
227
|
+
// POST(api.ts:66 同坑),contents 端点 POST 直接 404。
|
|
228
|
+
fallback: async () => {
|
|
229
|
+
const encoded = args.path.split('/').map(encodeURIComponent).join('/');
|
|
230
|
+
const refQuery = args.ref ? `?ref=${encodeURIComponent(args.ref)}` : '';
|
|
231
|
+
return executor.run(['api', `repos/${owner}/${repoName}/contents/${encoded}${refQuery}`, '-H', 'Accept: application/vnd.github.raw'], { cwd, sessionId, rawText: true, timeoutMs: 20_000 });
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
if (!res.ok)
|
|
235
|
+
throw new Error(res.error);
|
|
236
|
+
const content = res.rawOutput || '';
|
|
237
|
+
// 二进制防线:NUL 字节是文本内容的可靠否定信号(UTF-8 合法文本不含 NUL)。
|
|
238
|
+
// 不做 base64 直返——那会撑爆 Token 预算,引导 github_api binary channel。
|
|
239
|
+
if (content.includes('\0')) {
|
|
240
|
+
throw new Error(`「${args.path}」是二进制文件(检测到 NUL 字节),不适合文本读取。请改用 github_api(endpoint: 'repos/${owner}/${repoName}/contents/${args.path.split('/').map(encodeURIComponent).join('/')}', raw: true, binary 场景自行落盘) 获取字节流。`);
|
|
241
|
+
}
|
|
242
|
+
return { path: args.path, ref: args.ref, content, size: content.length, ...(res.degraded ? { degraded: res.degraded, degradedReason: res.degradedReason } : {}) };
|
|
243
|
+
}
|
|
244
|
+
case 'read_dir': {
|
|
245
|
+
let repoTarget = args.repo;
|
|
246
|
+
if (!repoTarget) {
|
|
247
|
+
const meta = await executor.getRepoMetadata(cwd, sessionId);
|
|
248
|
+
if (meta?.nameWithOwner)
|
|
249
|
+
repoTarget = meta.nameWithOwner;
|
|
250
|
+
}
|
|
251
|
+
if (!repoTarget)
|
|
252
|
+
throw new Error('read_dir 必须提供 repo [owner/repo],或在关联 Git 远程的工作区运行');
|
|
253
|
+
const [owner, repoName] = repoTarget.split('/');
|
|
254
|
+
if (!owner || !repoName)
|
|
255
|
+
throw new Error(`read_dir 需要 [owner/repo] 形式的仓库名(实际: ${repoTarget})`);
|
|
256
|
+
const refArgs = args.ref ? ['--ref', args.ref] : [];
|
|
257
|
+
const dirPath = args.path || '';
|
|
258
|
+
const res = await executor.runGuarded(['repo', 'read-dir', ...(dirPath ? [dirPath] : []), '-R', repoTarget, ...refArgs], {
|
|
259
|
+
...runOpts,
|
|
260
|
+
rawText: true,
|
|
261
|
+
timeoutMs: 20_000,
|
|
262
|
+
minVersion: '2.95.0',
|
|
263
|
+
capability: 'gh repo read-dir',
|
|
264
|
+
// 降级链:contents 端点返回 JSON 数组(dir 为含 type 字段的对象)。
|
|
265
|
+
// ref 同样走 URL query(-f 会把 GET 改写成 POST,见 read_file 注释)
|
|
266
|
+
fallback: async () => {
|
|
267
|
+
const encoded = dirPath.split('/').filter(Boolean).map(encodeURIComponent).join('/');
|
|
268
|
+
const refQuery = args.ref ? `?ref=${encodeURIComponent(args.ref)}` : '';
|
|
269
|
+
return executor.run(['api', `repos/${owner}/${repoName}/contents/${encoded}${refQuery}`], { cwd, sessionId, timeoutMs: 20_000 });
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
if (!res.ok)
|
|
273
|
+
throw new Error(res.error);
|
|
274
|
+
// 双链路归一为 items 数组:
|
|
275
|
+
// 主链路是 TSV 四列(type/name/mode/size),降级链 data 是 contents JSON 数组
|
|
276
|
+
let items;
|
|
277
|
+
if (res.degraded && Array.isArray(res.data)) {
|
|
278
|
+
items = res.data.map((e) => ({ type: String(e.type || 'file'), name: String(e.name || ''), mode: '-', size: typeof e.size === 'number' ? e.size : undefined }));
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
const tsv = (res.rawOutput || '').trim();
|
|
282
|
+
if (!tsv) {
|
|
283
|
+
return { message: `「${dirPath || '/'}」是空目录或不存在`, items: [], count: 0, ...(res.degraded ? { degraded: res.degraded, degradedReason: res.degradedReason } : {}) };
|
|
284
|
+
}
|
|
285
|
+
items = tsv.split('\n').map((line) => {
|
|
286
|
+
const cols = line.split('\t');
|
|
287
|
+
return { type: cols[0] || 'file', name: cols[1] || '', mode: cols[2] || '-', size: Number(cols[3]) || 0 };
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
return { path: dirPath || '/', items, count: items.length, ...(res.degraded ? { degraded: res.degraded, degradedReason: res.degradedReason } : {}) };
|
|
291
|
+
}
|
|
100
292
|
case 'sync': {
|
|
101
293
|
let repoTarget = args.repo;
|
|
102
294
|
if (!repoTarget) {
|
package/lib/tools/run.d.ts
CHANGED
|
@@ -35,6 +35,14 @@ export declare function createRunTool(executor: GhExecutor): {
|
|
|
35
35
|
type: string;
|
|
36
36
|
description: string;
|
|
37
37
|
};
|
|
38
|
+
all: {
|
|
39
|
+
type: string;
|
|
40
|
+
description: string;
|
|
41
|
+
};
|
|
42
|
+
name: {
|
|
43
|
+
type: string;
|
|
44
|
+
description: string;
|
|
45
|
+
};
|
|
38
46
|
ref: {
|
|
39
47
|
type: string;
|
|
40
48
|
description: string;
|
package/lib/tools/run.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { compareGhVersions } from '../executor.js';
|
|
1
2
|
import { renderRunMarkdown } from '../renderers/markdown.js';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
2
6
|
function normalizeOutput(data) {
|
|
3
7
|
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
4
8
|
return data;
|
|
@@ -17,8 +21,8 @@ export function createRunTool(executor) {
|
|
|
17
21
|
properties: {
|
|
18
22
|
action: {
|
|
19
23
|
type: 'string',
|
|
20
|
-
enum: ['list', 'view', 'log_failed', 'rerun', 'cancel', 'trigger'],
|
|
21
|
-
description: 'Actions
|
|
24
|
+
enum: ['list', 'view', 'log_failed', 'rerun', 'cancel', 'trigger', 'workflow_list', 'workflow_view', 'workflow_enable', 'workflow_disable', 'download'],
|
|
25
|
+
description: 'Actions 操作类型。trigger 主动触发 workflow_dispatch;workflow_* 查询与启停工作流;download 下载构建产物(落盘插件管理目录)。',
|
|
22
26
|
},
|
|
23
27
|
run_id: {
|
|
24
28
|
type: 'string',
|
|
@@ -43,7 +47,15 @@ export function createRunTool(executor) {
|
|
|
43
47
|
},
|
|
44
48
|
workflow: {
|
|
45
49
|
type: 'string',
|
|
46
|
-
description: '
|
|
50
|
+
description: '工作流名称、ID 或文件名(trigger/workflow_view 动作使用,例如 "pr-check.yml"、"CI" 或 "374382120")。',
|
|
51
|
+
},
|
|
52
|
+
all: {
|
|
53
|
+
type: 'boolean',
|
|
54
|
+
description: 'workflow_list 可选:列出全部工作流(含 disabled)。默认仅 active——排查「工作流为何不跑」时 disabled 状态本身就是答案,排障建议开启。',
|
|
55
|
+
},
|
|
56
|
+
name: {
|
|
57
|
+
type: 'string',
|
|
58
|
+
description: 'download 可选:仅下载指定名称的 artifact(可多次过滤,这里取单个名称)。',
|
|
47
59
|
},
|
|
48
60
|
ref: {
|
|
49
61
|
type: 'string',
|
|
@@ -132,6 +144,95 @@ export function createRunTool(executor) {
|
|
|
132
144
|
}
|
|
133
145
|
return { result: res.rawOutput || '未找到失败步骤的日志' };
|
|
134
146
|
}
|
|
147
|
+
case 'workflow_list': {
|
|
148
|
+
// 字段集为 gh 2.96 实测合法全集(仅 4 个,无 updatedAt——勘误 E1)。
|
|
149
|
+
// --all 暴露 disabled(排障语义:默认 active-only 对 Agent 是陷阱,D6 决策)。
|
|
150
|
+
const listCmd = ['workflow', 'list', ...repoArgs, '--json', 'id,name,path,state', '-L', String(typeof args.limit === 'number' && args.limit > 0 ? args.limit : Math.min(executor.defaultListLimit ?? 10, 50))];
|
|
151
|
+
if (args.all === true)
|
|
152
|
+
listCmd.push('--all');
|
|
153
|
+
const res = await executor.run(listCmd, runOpts);
|
|
154
|
+
if (!res.ok)
|
|
155
|
+
throw new Error(res.error);
|
|
156
|
+
return normalizeOutput(res.data || res.rawOutput);
|
|
157
|
+
}
|
|
158
|
+
case 'workflow_view': {
|
|
159
|
+
if (!args.workflow)
|
|
160
|
+
throw new Error('workflow_view 必须提供 workflow(工作流名称、ID 或文件名)');
|
|
161
|
+
// gh workflow view 不支持 --json(勘误 E2,实测 unknown flag)——rawText 直通,
|
|
162
|
+
// 仅头部三行结构化抽取;Recent runs 表保留原文(那是 run list 的投影,
|
|
163
|
+
// Agent 要结构化运行数据走 github_run action:'list',工具各司其职,D4 决策)。
|
|
164
|
+
const res = await executor.run(['workflow', 'view', args.workflow, ...repoArgs], { ...runOpts, rawText: true, timeoutMs: 20_000 });
|
|
165
|
+
if (!res.ok)
|
|
166
|
+
throw new Error(res.error);
|
|
167
|
+
const text = res.rawOutput || '';
|
|
168
|
+
const idMatch = /^ID:\s*(\S+)/m.exec(text);
|
|
169
|
+
const totalMatch = /^Total runs\s+(\S+)/m.exec(text);
|
|
170
|
+
// 首行是「名称 - 文件名」组合行(实测形态),整行保留作 workflow 标识
|
|
171
|
+
const firstLine = text.split('\n', 1)[0]?.trim() || args.workflow;
|
|
172
|
+
const recentIdx = text.indexOf('Recent runs');
|
|
173
|
+
return {
|
|
174
|
+
workflow: firstLine,
|
|
175
|
+
id: idMatch ? idMatch[1] : undefined,
|
|
176
|
+
totalRuns: totalMatch ? totalMatch[1] : undefined,
|
|
177
|
+
recentRuns: recentIdx >= 0 ? text.slice(recentIdx).trim() : text.trim(),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
case 'workflow_enable':
|
|
181
|
+
case 'workflow_disable': {
|
|
182
|
+
if (!args.workflow)
|
|
183
|
+
throw new Error(`${args.action} 必须提供 workflow(工作流名称、ID 或文件名)`);
|
|
184
|
+
// 可逆状态开关免 confirm(S3 方案 D4),但 disable 的后果(trigger 静默失败)
|
|
185
|
+
// 必须写进返回——Agent 不知情会把 CI 故障误判为代码问题
|
|
186
|
+
const res = await executor.run(['workflow', args.action === 'workflow_enable' ? 'enable' : 'disable', args.workflow, ...repoArgs], runOpts);
|
|
187
|
+
if (!res.ok)
|
|
188
|
+
throw new Error(res.error);
|
|
189
|
+
return args.action === 'workflow_disable'
|
|
190
|
+
? { message: `工作流「${args.workflow}」已禁用。注意:下次 trigger 将静默失败,直至重新 enable(workflow_enable)。` }
|
|
191
|
+
: { message: `工作流「${args.workflow}」已启用,恢复可触发状态。` };
|
|
192
|
+
}
|
|
193
|
+
case 'download': {
|
|
194
|
+
if (!args.run_id)
|
|
195
|
+
throw new Error('download 操作必须提供 run_id');
|
|
196
|
+
if (args.name && typeof args.name !== 'string')
|
|
197
|
+
throw new Error('name 必须是字符串(artifact 名称过滤)');
|
|
198
|
+
// GHSA-39wj-f2f4-978v(gh<2.102 符号链接路径穿越)防线:落盘目录由插件
|
|
199
|
+
// 固定派生(owner/repo/run_id 三级),不暴露 dir 参数给用户——攻击面
|
|
200
|
+
// 收敛到一个 mkdirSync 调用点;用户无法注入 ../ 或符号链接目标。
|
|
201
|
+
const artifactsRoot = path.join(os.homedir(), '.dsh', 'data', 'dsh-github-flow', 'artifacts', ...repoTarget.split('/'), String(args.run_id));
|
|
202
|
+
fs.mkdirSync(artifactsRoot, { recursive: true });
|
|
203
|
+
const dlCmd = ['run', 'download', String(args.run_id), ...repoArgs, '-D', artifactsRoot];
|
|
204
|
+
if (args.name) {
|
|
205
|
+
dlCmd.push('-n', args.name);
|
|
206
|
+
}
|
|
207
|
+
const res = await executor.run(dlCmd, { ...runOpts, timeoutMs: 120_000 });
|
|
208
|
+
if (!res.ok)
|
|
209
|
+
throw new Error(res.error);
|
|
210
|
+
// 落盘清单:递归枚举产物文件(相对路径+大小),供 Agent 直接引用
|
|
211
|
+
const files = [];
|
|
212
|
+
const walk = (dir, prefix) => {
|
|
213
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
214
|
+
const full = path.join(dir, entry.name);
|
|
215
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
216
|
+
if (entry.isDirectory())
|
|
217
|
+
walk(full, rel);
|
|
218
|
+
else
|
|
219
|
+
files.push({ file: rel, size: fs.statSync(full).size });
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
walk(artifactsRoot, '');
|
|
223
|
+
// GHSA-39wj-f2f4-978v:2.102.0 修复符号链接路径穿越。低版本不硬拒绝
|
|
224
|
+
// (受控仓库下载是合理需求),但安全提示必须进返回值引导升级。
|
|
225
|
+
const ghVersion = await executor.getGhVersion();
|
|
226
|
+
const securityNote = ghVersion && compareGhVersions(ghVersion, '2.102.0') < 0
|
|
227
|
+
? `安全提示:当前 gh ${ghVersion} 低于 2.102.0(修复 GHSA-39wj-f2f4-978v 符号链接路径穿越)。建议升级 gh(Windows: winget upgrade GitHub.cli / macOS: brew upgrade gh)。`
|
|
228
|
+
: undefined;
|
|
229
|
+
return {
|
|
230
|
+
message: `Run #${args.run_id} 产物已下载 ${files.length} 个文件${args.name ? `(过滤: ${args.name})` : ''}`,
|
|
231
|
+
directory: artifactsRoot,
|
|
232
|
+
files,
|
|
233
|
+
...(securityNote ? { securityNote } : {}),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
135
236
|
case 'trigger': {
|
|
136
237
|
if (!args.workflow)
|
|
137
238
|
throw new Error('trigger 操作必须提供 workflow(工作流名称或文件名,例如 "pr-check.yml")');
|
package/lib/tools/secret.d.ts
CHANGED