dsh-github-flow 0.2.0 → 0.2.1
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 +31 -12
- package/README.md +33 -14
- package/docs/UPGRADE-NOTES.md +93 -0
- package/docs/architecture.md +33 -18
- 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 +15 -4
- 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/client.js +411 -44
- package/lib/commands/gh.js +11 -2
- package/lib/executor.js +122 -12
- package/lib/main.js +9 -0
- package/lib/renderers/markdown.d.ts +6 -1
- package/lib/renderers/markdown.js +41 -14
- package/lib/tools/help.js +12 -0
- package/lib/tools/image-utils.d.ts +79 -0
- package/lib/tools/image-utils.js +247 -0
- package/lib/tools/issue.d.ts +9 -4
- package/lib/tools/issue.js +75 -4
- package/lib/tools/pr.d.ts +8 -4
- package/lib/tools/pr.js +80 -6
- package/lib/tools/repo.d.ts +12 -4
- package/lib/tools/repo.js +71 -3
- package/lib/types.d.ts +12 -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
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue / PR 共享的图片附件工具链。
|
|
3
|
+
*
|
|
4
|
+
* 为什么从 issue.ts 抽出:PR 与 Issue 的图片机制完全同构(同样的
|
|
5
|
+
* user-attachments 域名、<img>/![]() 双语法、同一评论 API),view_images
|
|
6
|
+
* 在两侧落地时共享提取/嗅探/护栏/落盘全部逻辑,避免两份拷贝漂移。
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
/**
|
|
13
|
+
* 把 README 中的仓库内相对图片路径解析为 raw.githubusercontent 绝对 URL。
|
|
14
|
+
* 强制落在 raw 域名模板内——不存在拼出任意域名的可能(SSRF 白名单天然覆盖)。
|
|
15
|
+
* 路径以 ./ ../ /docs/ 等形态出现;URL 编码空格等字符;无效形态返回 null。
|
|
16
|
+
*/
|
|
17
|
+
export function resolveRelativeImageUrl(rel, owner, repo, branch) {
|
|
18
|
+
if (typeof rel !== 'string' || !rel || rel.startsWith('#'))
|
|
19
|
+
return null;
|
|
20
|
+
// 任何带 scheme 的(http/javascript/data/file)与 Windows 盘符/UNC 路径都不是
|
|
21
|
+
// 仓库内相对路径——统一拒绝,只接受纯 POSIX 相对形态
|
|
22
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(rel))
|
|
23
|
+
return null;
|
|
24
|
+
if (/^[a-z]:[\\/]/i.test(rel) || rel.startsWith('\\\\') || rel.includes('\\'))
|
|
25
|
+
return null;
|
|
26
|
+
// 去掉 ./ 前缀;拒绝越出仓库的绝对路径与查询串
|
|
27
|
+
let p = rel.replace(/^\.\//, '');
|
|
28
|
+
if (p.startsWith('/') || p.includes('..') || p.includes('?') || p.includes('#'))
|
|
29
|
+
return null;
|
|
30
|
+
// 只放行仓库路径的合法字符集(字母数字、CJK/Unicode 文件名、._/- 空格),
|
|
31
|
+
// 从根上挡住把控制字符/协议残留拼进 raw URL 的畸形输入
|
|
32
|
+
if (!/^[\w\-. /\u0080-\uffff]+$/u.test(p))
|
|
33
|
+
return null;
|
|
34
|
+
try {
|
|
35
|
+
const encoded = p.split('/').map(encodeURIComponent).join('/');
|
|
36
|
+
return `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(branch)}/${encoded}`;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 判断 URL 是否为徽章类小图标(shields.io / badge 类 svg)。
|
|
44
|
+
* README 图片场景默认过滤:用户要看的是截图/架构图,版本徽章是排版噪音;
|
|
45
|
+
* include_badges=true 时保留(有人确实要查徽章状态)。
|
|
46
|
+
*/
|
|
47
|
+
export function isBadgeImageUrl(url) {
|
|
48
|
+
return /img\.shields\.io|badgen\.net|badge|\/svg\?style=/i.test(url);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 从正文(Issue/PR body 或评论)提取图片 URL。
|
|
52
|
+
* 域名白名单 + https 扩展名双条件:
|
|
53
|
+
* - GitHub 四资产域名直接放行(用户上传/旧版上传/仓库引用/camo 代理);
|
|
54
|
+
* - 外链强制 https 且带图片扩展名——关闭恶意正文诱导访问内网元数据端点
|
|
55
|
+
* (169.254.169.254)等 SSRF 面,file:/// 与 http:// 一律拒绝。
|
|
56
|
+
*/
|
|
57
|
+
export function extractImageUrls(body) {
|
|
58
|
+
if (typeof body !== 'string' || !body)
|
|
59
|
+
return [];
|
|
60
|
+
const urls = new Set();
|
|
61
|
+
// HTML <img src="..."> 与 Markdown  两种形态
|
|
62
|
+
const pattern = /(?:<img[^>]+src="([^"]+)"|!\[[^\]]*\]\(([^)\s]+)[^)]*\))/gi;
|
|
63
|
+
let m;
|
|
64
|
+
while ((m = pattern.exec(body)) !== null) {
|
|
65
|
+
const url = m[1] || m[2];
|
|
66
|
+
if (!url)
|
|
67
|
+
continue;
|
|
68
|
+
const isGithubAsset = url.startsWith('https://github.com/user-attachments/') ||
|
|
69
|
+
url.startsWith('https://user-images.githubusercontent.com/') ||
|
|
70
|
+
url.startsWith('https://raw.githubusercontent.com/') ||
|
|
71
|
+
url.startsWith('https://camo.githubusercontent.com/');
|
|
72
|
+
const looksLikeImage = url.startsWith('https://') &&
|
|
73
|
+
/\.(png|jpe?g|gif|webp|bmp|svg)([?#]|$)/i.test(url);
|
|
74
|
+
if (isGithubAsset || looksLikeImage)
|
|
75
|
+
urls.add(url);
|
|
76
|
+
}
|
|
77
|
+
return Array.from(urls);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* README 专用提取:绝对 URL 走通用提取器;仓库内相对路径解析为 raw URL。
|
|
81
|
+
* 默认过滤徽章小图标(isBadgeImageUrl),include_badges 开关保留。
|
|
82
|
+
*/
|
|
83
|
+
export function extractReadmeImageUrls(body, ctx) {
|
|
84
|
+
if (typeof body !== 'string' || !body)
|
|
85
|
+
return { urls: [], relativeResolved: 0, badgeFiltered: 0 };
|
|
86
|
+
const absolute = extractImageUrls(body);
|
|
87
|
+
const result = [];
|
|
88
|
+
let badgeFiltered = 0;
|
|
89
|
+
const keep = (u) => {
|
|
90
|
+
if (!ctx.includeBadges && isBadgeImageUrl(u)) {
|
|
91
|
+
badgeFiltered++;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
result.push(u);
|
|
95
|
+
};
|
|
96
|
+
for (const u of absolute)
|
|
97
|
+
keep(u);
|
|
98
|
+
// 相对路径:同一正则源头重新扫,取原始 src 再解析(绝对 URL 的正则会漏掉 ./docs/x.png)
|
|
99
|
+
let relativeResolved = 0;
|
|
100
|
+
const pattern = /(?:<img[^>]+src="([^"]+)"|!\[[^\]]*\]\(([^)\s]+)[^)]*\))/gi;
|
|
101
|
+
let m;
|
|
102
|
+
while ((m = pattern.exec(body)) !== null) {
|
|
103
|
+
const raw = m[1] || m[2];
|
|
104
|
+
if (!raw || /^[a-z]+:\/\//i.test(raw))
|
|
105
|
+
continue; // 只处理相对路径
|
|
106
|
+
const resolved = resolveRelativeImageUrl(raw, ctx.owner, ctx.repo, ctx.branch);
|
|
107
|
+
if (resolved) {
|
|
108
|
+
keep(resolved);
|
|
109
|
+
relativeResolved++;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { urls: Array.from(new Set(result)), relativeResolved, badgeFiltered };
|
|
113
|
+
}
|
|
114
|
+
/** 从魔数推断图片 MIME(下载内容才是事实源——URL 扩展名可伪造) */
|
|
115
|
+
export function sniffImageMime(buf) {
|
|
116
|
+
// 长度下限只挡二进制格式(PNG/JPEG/GIF/WEBP 均有 ≥12 字节头);SVG 是文本,
|
|
117
|
+
// 无固定头长度——短到 4 字节的 '<svg' 也合法,必须在长度检查之前判定
|
|
118
|
+
if (buf.slice(0, 4).toString('ascii').toLowerCase() === '<svg')
|
|
119
|
+
return 'image/svg+xml';
|
|
120
|
+
if (buf.length < 12)
|
|
121
|
+
return null;
|
|
122
|
+
if (buf[0] === 0x89 && buf[1] === 0x50)
|
|
123
|
+
return 'image/png'; // PNG
|
|
124
|
+
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff)
|
|
125
|
+
return 'image/jpeg'; // JPEG
|
|
126
|
+
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46)
|
|
127
|
+
return 'image/gif'; // GIF
|
|
128
|
+
if (buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50)
|
|
129
|
+
return 'image/webp'; // RIFF....WEBP
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
/** 图片下载护栏:按真实资源消耗计算——单图字节与目录总量,无张数截断 */
|
|
133
|
+
export const IMG_MAX_BYTES = 5 * 1024 * 1024; // 单图 5MB
|
|
134
|
+
export const IMG_DIR_MAX_BYTES = 15 * 1024 * 1024; // 图片缓存目录总量 15MB
|
|
135
|
+
/** 图片落盘目录(按内容寻址命名,重复下载天然去重) */
|
|
136
|
+
function imageStoreDir() {
|
|
137
|
+
return path.join(os.homedir(), '.dsh', 'data', 'dsh-github-flow', 'images');
|
|
138
|
+
}
|
|
139
|
+
/** 粗略统计图片目录现有总量(遍历一次,文件数有护栏上限不会失控) */
|
|
140
|
+
function imageDirSize(dir) {
|
|
141
|
+
let total = 0;
|
|
142
|
+
try {
|
|
143
|
+
for (const f of fs.readdirSync(dir)) {
|
|
144
|
+
try {
|
|
145
|
+
total += fs.statSync(path.join(dir, f)).size;
|
|
146
|
+
}
|
|
147
|
+
catch { }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch { }
|
|
151
|
+
return total;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* 交集过滤:把调用方请求的 URL 硬约束在正文实际提取的合法清单内。
|
|
155
|
+
* 为什么必须交集而非直接信任传入:image_urls 来自模型生成参数,存在幻觉或
|
|
156
|
+
* 被诱导传入任意 URL 的可能——交集把下载目标限定在本条 Issue/PR 引用过的
|
|
157
|
+
* 图片范围,SSRF 防线(提取阶段的 https+域名白名单)自动覆盖全部下载目标。
|
|
158
|
+
* 返回 { matched, rejected }:matched 保持请求顺序(Agent 按自己的关注顺序拿结果)。
|
|
159
|
+
*/
|
|
160
|
+
export function filterRequestedUrls(requested, extracted) {
|
|
161
|
+
const matched = [];
|
|
162
|
+
const rejected = [];
|
|
163
|
+
const allow = new Set(extracted);
|
|
164
|
+
for (const u of Array.isArray(requested) ? requested : []) {
|
|
165
|
+
if (typeof u === 'string' && u && allow.has(u)) {
|
|
166
|
+
matched.push(u);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
rejected.push(typeof u === 'string' && u ? u : JSON.stringify(u));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { matched, rejected };
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* 批量下载图片并落盘(Issue/PR 的 view_images 共用执行体)。
|
|
176
|
+
* 每张经 executor 的二进制通道(gh api),魔数校验 + 三重护栏 + 内容寻址;
|
|
177
|
+
* 权威目录不可写时降级系统临时目录(只损失跨会话去重)。
|
|
178
|
+
* 单张失败不中断批次——failures 记录原因由调用方透传给 Agent。
|
|
179
|
+
*/
|
|
180
|
+
export async function downloadImages(urls, run) {
|
|
181
|
+
let dir = imageStoreDir();
|
|
182
|
+
try {
|
|
183
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
dir = path.join(os.tmpdir(), 'dsh-github-flow-images');
|
|
187
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
188
|
+
}
|
|
189
|
+
let dirSize = imageDirSize(dir);
|
|
190
|
+
const images = [];
|
|
191
|
+
const failures = [];
|
|
192
|
+
const remaining = [];
|
|
193
|
+
let budgetExhausted = false;
|
|
194
|
+
// 无张数截断:字节护栏按真实资源消耗逐张判定。触顶后仍继续遍历——
|
|
195
|
+
// 剩余 URL 全部收进 remaining 供 Agent 分批续传,而非静默丢弃。
|
|
196
|
+
for (const url of urls) {
|
|
197
|
+
if (budgetExhausted) {
|
|
198
|
+
remaining.push(url);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const res = await run(url);
|
|
202
|
+
const buf = res.ok && Buffer.isBuffer(res.data) ? res.data : null;
|
|
203
|
+
if (!buf) {
|
|
204
|
+
failures.push(`${url.slice(-24)}: ${res.error || '非二进制响应'}`);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (buf.length > IMG_MAX_BYTES) {
|
|
208
|
+
failures.push(`${url.slice(-24)}: 超过单图 5MB 上限(${(buf.length / 1024 / 1024).toFixed(1)}MB)`);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const mime = sniffImageMime(buf);
|
|
212
|
+
if (!mime) {
|
|
213
|
+
failures.push(`${url.slice(-24)}: 内容不是图片(魔数校验失败)`);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (dirSize + buf.length > IMG_DIR_MAX_BYTES) {
|
|
217
|
+
budgetExhausted = true;
|
|
218
|
+
remaining.push(url);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
// 内容寻址(sha256 前 16 位)+ 扩展名:重复下载同一图去重,文件名可读
|
|
222
|
+
const ext = mime.includes('svg') ? 'svg' : mime.split('/')[1].replace('jpeg', 'jpg');
|
|
223
|
+
const file = path.join(dir, `${crypto.createHash('sha256').update(buf).digest('hex').slice(0, 16)}.${ext}`);
|
|
224
|
+
fs.writeFileSync(file, buf);
|
|
225
|
+
// 累计已写入量:下张图的总量护栏判定必须基于最新值(否则多图可突破上限)
|
|
226
|
+
dirSize += buf.length;
|
|
227
|
+
images.push({ file, mimeType: mime, url, bytes: buf.length });
|
|
228
|
+
}
|
|
229
|
+
// 触顶反馈(用户裁定的续传闭环):已获取 N/M + 结构化剩余清单,
|
|
230
|
+
// Agent 直接 image_urls: remaining 续传,无需从文本里解析 URL
|
|
231
|
+
if (remaining.length > 0) {
|
|
232
|
+
failures.push(`图片缓存目录达 15MB 上限:本次获取 ${images.length}/${urls.length} 张,剩余 ${remaining.length} 张未下载(可分批续传)`);
|
|
233
|
+
}
|
|
234
|
+
return { images, failures, remaining: remaining.length ? remaining : undefined };
|
|
235
|
+
}
|
|
236
|
+
/** render 的 image 内容块构造(text 块由调用方自行拼):仅双合法字段才产出块 */
|
|
237
|
+
export function buildImageBlocks(value) {
|
|
238
|
+
const blocks = [];
|
|
239
|
+
if (Array.isArray(value?.images)) {
|
|
240
|
+
for (const img of value.images) {
|
|
241
|
+
if (img && typeof img.file === 'string' && typeof img.mimeType === 'string') {
|
|
242
|
+
blocks.push({ type: 'image', path: img.file, mimeType: img.mimeType });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return blocks;
|
|
247
|
+
}
|
package/lib/tools/issue.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { GhExecutor } from '../executor.js';
|
|
2
|
+
export { extractImageUrls as extractIssueImageUrls } from './image-utils.js';
|
|
2
3
|
export declare function createIssueTool(executor: GhExecutor): {
|
|
3
4
|
name: string;
|
|
4
5
|
description: string;
|
|
@@ -71,6 +72,13 @@ export declare function createIssueTool(executor: GhExecutor): {
|
|
|
71
72
|
type: string;
|
|
72
73
|
description: string;
|
|
73
74
|
};
|
|
75
|
+
image_urls: {
|
|
76
|
+
type: string;
|
|
77
|
+
items: {
|
|
78
|
+
type: string;
|
|
79
|
+
};
|
|
80
|
+
description: string;
|
|
81
|
+
};
|
|
74
82
|
};
|
|
75
83
|
required: string[];
|
|
76
84
|
};
|
|
@@ -78,10 +86,7 @@ export declare function createIssueTool(executor: GhExecutor): {
|
|
|
78
86
|
schema: {
|
|
79
87
|
type: string;
|
|
80
88
|
};
|
|
81
|
-
render(args: any, value: any):
|
|
82
|
-
type: string;
|
|
83
|
-
text: string;
|
|
84
|
-
}[];
|
|
89
|
+
render(args: any, value: any): any[];
|
|
85
90
|
};
|
|
86
91
|
execute(args: any, execContext: any): Promise<Record<string, any>>;
|
|
87
92
|
};
|
package/lib/tools/issue.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { renderIssueMarkdown } 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;
|
|
@@ -8,6 +9,8 @@ function normalizeOutput(data) {
|
|
|
8
9
|
}
|
|
9
10
|
return { result: data !== undefined && data !== null ? data : '' };
|
|
10
11
|
}
|
|
12
|
+
// 共享图片工具链的同名再导出:phase8 测试直接 import 本模块,保持路径稳定
|
|
13
|
+
export { extractImageUrls as extractIssueImageUrls } from './image-utils.js';
|
|
11
14
|
export function createIssueTool(executor) {
|
|
12
15
|
return {
|
|
13
16
|
name: 'github_issue',
|
|
@@ -17,8 +20,8 @@ export function createIssueTool(executor) {
|
|
|
17
20
|
properties: {
|
|
18
21
|
action: {
|
|
19
22
|
type: 'string',
|
|
20
|
-
enum: ['list', 'view', 'create', 'comment', 'close', 'reopen', 'edit', 'develop'],
|
|
21
|
-
description: 'Issue 操作类型。',
|
|
23
|
+
enum: ['list', 'view', 'create', 'comment', 'close', 'reopen', 'edit', 'develop', 'view_images'],
|
|
24
|
+
description: 'Issue 操作类型。view_images 下载正文内的图片到本地并返回可渲染的图片内容块(Agent 可直接"看"用户报障截图)。',
|
|
22
25
|
},
|
|
23
26
|
issue_number: {
|
|
24
27
|
type: 'number',
|
|
@@ -73,13 +76,20 @@ export function createIssueTool(executor) {
|
|
|
73
76
|
type: 'boolean',
|
|
74
77
|
description: '高危破坏性操作确认标志(close 动作必需)。若为 false 则仅输出待关闭警告。',
|
|
75
78
|
},
|
|
79
|
+
image_urls: {
|
|
80
|
+
type: 'array',
|
|
81
|
+
items: { type: 'string' },
|
|
82
|
+
description: 'view_images 可选:仅下载指定 URL 的图片(从 view 返回的 images 清单中选取,不在清单内的会被拒绝)。留空下载全部(正文+评论)。',
|
|
83
|
+
},
|
|
76
84
|
},
|
|
77
85
|
required: ['action'],
|
|
78
86
|
},
|
|
79
87
|
output: {
|
|
80
88
|
schema: { type: 'object' },
|
|
81
89
|
render(args, value) {
|
|
82
|
-
|
|
90
|
+
// view_images 的图片以宿主 image 内容块输出:模型与人类都能直接看到图,
|
|
91
|
+
// base64 会污染上下文(一张截图 ~88KB),引用落盘文件由宿主按需加载
|
|
92
|
+
return [{ type: 'text', text: renderIssueMarkdown(args, value) }, ...buildImageBlocks(value)];
|
|
83
93
|
},
|
|
84
94
|
},
|
|
85
95
|
async execute(args, execContext) {
|
|
@@ -126,7 +136,68 @@ export function createIssueTool(executor) {
|
|
|
126
136
|
const res = await executor.run(['issue', 'view', String(args.issue_number), ...repoArgs, '--json', 'number,title,body,state,author,labels,assignees,comments,url'], runOpts);
|
|
127
137
|
if (!res.ok)
|
|
128
138
|
throw new Error(res.error);
|
|
129
|
-
|
|
139
|
+
const out = normalizeOutput(res.data || res.rawOutput);
|
|
140
|
+
// 附带图片 URL 清单(正文+评论,与 view_images 提取范围同源):
|
|
141
|
+
// Agent 按正文里的原始 URL 决策,需要看图时用 image_urls 精准选取
|
|
142
|
+
const bodies = [typeof out.body === 'string' ? out.body : ''];
|
|
143
|
+
if (Array.isArray(out.comments)) {
|
|
144
|
+
for (const c of out.comments) {
|
|
145
|
+
if (c && typeof c.body === 'string')
|
|
146
|
+
bodies.push(c.body);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
out.images = Array.from(new Set(bodies.flatMap((b) => extractImageUrls(b))));
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
case 'view_images': {
|
|
153
|
+
if (!args.issue_number)
|
|
154
|
+
throw new Error('view_images 操作必须提供 issue_number');
|
|
155
|
+
// 第一步:取正文+评论提取图片 URL(与 view 清单同源)
|
|
156
|
+
const detail = await executor.run(['issue', 'view', String(args.issue_number), ...repoArgs, '--json', 'body,comments'], runOpts);
|
|
157
|
+
if (!detail.ok)
|
|
158
|
+
throw new Error(detail.error);
|
|
159
|
+
const d = detail.data && typeof detail.data === 'object' ? detail.data : {};
|
|
160
|
+
const bodies = [typeof d.body === 'string' ? d.body : ''];
|
|
161
|
+
if (Array.isArray(d.comments)) {
|
|
162
|
+
for (const c of d.comments) {
|
|
163
|
+
if (c && typeof c.body === 'string')
|
|
164
|
+
bodies.push(c.body);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const extracted = Array.from(new Set(bodies.flatMap((b) => extractImageUrls(b))));
|
|
168
|
+
if (extracted.length === 0) {
|
|
169
|
+
return { message: `Issue #${args.issue_number} 正文与评论中未发现图片附件。` };
|
|
170
|
+
}
|
|
171
|
+
// 第二步:目标选择——不传 image_urls 下载全部;传入则与正文清单
|
|
172
|
+
// 严格交集(防幻觉/诱导的任意 URL 下载),空数组视为误传明确报错
|
|
173
|
+
let targets = extracted;
|
|
174
|
+
const rejections = [];
|
|
175
|
+
if (args.image_urls !== undefined) {
|
|
176
|
+
if (!Array.isArray(args.image_urls) || args.image_urls.length === 0) {
|
|
177
|
+
throw new Error('image_urls 为空数组:如需下载全部请省略该参数,如需指定请传入 view 返回清单中的 URL。');
|
|
178
|
+
}
|
|
179
|
+
const filtered = filterRequestedUrls(args.image_urls, extracted);
|
|
180
|
+
targets = filtered.matched;
|
|
181
|
+
for (const bad of filtered.rejected) {
|
|
182
|
+
rejections.push(`${String(bad).slice(-24)}: 不在本 Issue 图片清单中,已拒绝(防任意下载)`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// 第三步:共享下载执行体(gh api 二进制通道 + 魔数校验 + 字节护栏 + 内容寻址落盘)
|
|
186
|
+
const { images, failures: dlFailures, remaining } = await downloadImages(targets, (url) => executor.run(['api', url], { ...runOpts, binary: true, timeoutMs: 30_000 }));
|
|
187
|
+
const failures = [...rejections, ...dlFailures];
|
|
188
|
+
if (images.length === 0 && rejections.length === 0 && !remaining) {
|
|
189
|
+
return { message: `Issue #${args.issue_number} 的图片全部下载失败:`, failures };
|
|
190
|
+
}
|
|
191
|
+
if (images.length === 0) {
|
|
192
|
+
return { message: `Issue #${args.issue_number} 未能获取任何图片(请求 ${targets.length} 张,被拒 ${rejections.length} 张)`, failures, remaining };
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
message: `Issue #${args.issue_number} 成功获取 ${images.length} 张图片${failures.length ? `(${failures.length} 条失败/提示)` : ''}:`,
|
|
196
|
+
images,
|
|
197
|
+
failures: failures.length ? failures : undefined,
|
|
198
|
+
// 续传清单:触顶时 Agent 直接 image_urls: remaining 发起下一批
|
|
199
|
+
remaining,
|
|
200
|
+
};
|
|
130
201
|
}
|
|
131
202
|
case 'create': {
|
|
132
203
|
const cmd = ['issue', 'create', ...repoArgs, '--title', args.title || '', '--body', args.body || ''];
|
package/lib/tools/pr.d.ts
CHANGED
|
@@ -77,6 +77,13 @@ export declare function createPrTool(executor: GhExecutor): {
|
|
|
77
77
|
type: string;
|
|
78
78
|
description: string;
|
|
79
79
|
};
|
|
80
|
+
image_urls: {
|
|
81
|
+
type: string;
|
|
82
|
+
items: {
|
|
83
|
+
type: string;
|
|
84
|
+
};
|
|
85
|
+
description: string;
|
|
86
|
+
};
|
|
80
87
|
};
|
|
81
88
|
required: string[];
|
|
82
89
|
};
|
|
@@ -84,10 +91,7 @@ export declare function createPrTool(executor: GhExecutor): {
|
|
|
84
91
|
schema: {
|
|
85
92
|
type: string;
|
|
86
93
|
};
|
|
87
|
-
render(args: any, value: any):
|
|
88
|
-
type: string;
|
|
89
|
-
text: string;
|
|
90
|
-
}[];
|
|
94
|
+
render(args: any, value: any): any[];
|
|
91
95
|
};
|
|
92
96
|
execute(args: any, execContext: any): Promise<Record<string, any>>;
|
|
93
97
|
};
|
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'],
|
|
22
|
+
description: 'PR 操作类型。view_images 下载正文与评论中的图片(UI 变更对比图、复现截图等)到本地并以图片内容块返回,供 Agent 审查时直接查看。',
|
|
22
23
|
},
|
|
23
24
|
pr_number: {
|
|
24
25
|
type: 'number',
|
|
@@ -87,13 +88,18 @@ export function createPrTool(executor) {
|
|
|
87
88
|
type: 'boolean',
|
|
88
89
|
description: '是否只预演不真正创建(create 操作时使用,默认 false)。透传 gh --dry-run:打印将要创建的 PR 详情而不落库,用于提交前确认 head/base/title 是否正确。',
|
|
89
90
|
},
|
|
91
|
+
image_urls: {
|
|
92
|
+
type: 'array',
|
|
93
|
+
items: { type: 'string' },
|
|
94
|
+
description: 'view_images 可选:仅下载指定 URL 的图片(从 view 返回的 images 清单中选取,不在清单内的会被拒绝)。留空下载全部(正文+评论)。',
|
|
95
|
+
},
|
|
90
96
|
},
|
|
91
97
|
required: ['action'],
|
|
92
98
|
},
|
|
93
99
|
output: {
|
|
94
100
|
schema: { type: 'object' },
|
|
95
101
|
render(args, value) {
|
|
96
|
-
return [{ type: 'text', text: renderPrMarkdown(args, value) }];
|
|
102
|
+
return [{ type: 'text', text: renderPrMarkdown(args, value) }, ...buildImageBlocks(value)];
|
|
97
103
|
},
|
|
98
104
|
},
|
|
99
105
|
async execute(args, execContext) {
|
|
@@ -130,14 +136,82 @@ export function createPrTool(executor) {
|
|
|
130
136
|
}
|
|
131
137
|
case 'view': {
|
|
132
138
|
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);
|
|
139
|
+
const res = await executor.run(['pr', 'view', ...target, ...repoArgs, '--json', 'number,title,body,state,author,labels,assignees,reviewDecision,mergeable,statusCheckRollup,url,comments'], runOpts);
|
|
134
140
|
if (!res.ok)
|
|
135
141
|
throw new Error(res.error);
|
|
136
|
-
|
|
142
|
+
const out = normalizeOutput(res.data || res.rawOutput);
|
|
143
|
+
// 附带图片 URL 清单(正文+评论,与 view_images 提取范围同源):
|
|
144
|
+
// Agent 按正文里的原始 URL 决策,需要看图时用 image_urls 精准选取
|
|
145
|
+
const bodies = [typeof out.body === 'string' ? out.body : ''];
|
|
146
|
+
if (Array.isArray(out.comments)) {
|
|
147
|
+
for (const c of out.comments) {
|
|
148
|
+
if (c && typeof c.body === 'string')
|
|
149
|
+
bodies.push(c.body);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
out.images = Array.from(new Set(bodies.flatMap((b) => extractImageUrls(b))));
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
case 'view_images': {
|
|
156
|
+
if (!args.pr_number)
|
|
157
|
+
throw new Error('view_images 操作必须提供 pr_number');
|
|
158
|
+
// PR 与 Issue 图片机制同构;评论走同一 issues-comments API(实测 PR 评论
|
|
159
|
+
// 的 <img> 形态与 issue 完全一致),正文 + 评论合并提取一次覆盖全部
|
|
160
|
+
const detail = await executor.run(['pr', 'view', String(args.pr_number), ...repoArgs, '--json', 'body,comments'], runOpts);
|
|
161
|
+
if (!detail.ok)
|
|
162
|
+
throw new Error(detail.error);
|
|
163
|
+
const d = detail.data && typeof detail.data === 'object' ? detail.data : {};
|
|
164
|
+
const bodies = [];
|
|
165
|
+
if (typeof d.body === 'string')
|
|
166
|
+
bodies.push(d.body);
|
|
167
|
+
if (Array.isArray(d.comments)) {
|
|
168
|
+
for (const c of d.comments) {
|
|
169
|
+
if (c && typeof c.body === 'string')
|
|
170
|
+
bodies.push(c.body);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// 多来源合并去重(Set 语义由 extractImageUrls 内部保证)
|
|
174
|
+
const extracted = Array.from(new Set(bodies.flatMap((b) => extractImageUrls(b))));
|
|
175
|
+
if (extracted.length === 0) {
|
|
176
|
+
return { message: `PR #${args.pr_number} 正文与评论中未发现图片附件。` };
|
|
177
|
+
}
|
|
178
|
+
// 目标选择:不传 image_urls 下载全部;传入则与清单严格交集
|
|
179
|
+
//(防幻觉/诱导的任意 URL 下载),空数组视为误传明确报错
|
|
180
|
+
let targets = extracted;
|
|
181
|
+
const rejections = [];
|
|
182
|
+
if (args.image_urls !== undefined) {
|
|
183
|
+
if (!Array.isArray(args.image_urls) || args.image_urls.length === 0) {
|
|
184
|
+
throw new Error('image_urls 为空数组:如需下载全部请省略该参数,如需指定请传入 view 返回清单中的 URL。');
|
|
185
|
+
}
|
|
186
|
+
const filtered = filterRequestedUrls(args.image_urls, extracted);
|
|
187
|
+
targets = filtered.matched;
|
|
188
|
+
for (const bad of filtered.rejected) {
|
|
189
|
+
rejections.push(`${String(bad).slice(-24)}: 不在本 PR 图片清单中,已拒绝(防任意下载)`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const { images, failures: dlFailures, remaining } = await downloadImages(targets, (url) => executor.run(['api', url], { ...runOpts, binary: true, timeoutMs: 30_000 }));
|
|
193
|
+
const failures = [...rejections, ...dlFailures];
|
|
194
|
+
if (images.length === 0 && rejections.length === 0 && !remaining) {
|
|
195
|
+
return { message: `PR #${args.pr_number} 的图片全部下载失败:`, failures };
|
|
196
|
+
}
|
|
197
|
+
if (images.length === 0) {
|
|
198
|
+
return { message: `PR #${args.pr_number} 未能获取任何图片(请求 ${targets.length} 张,被拒 ${rejections.length} 张)`, failures, remaining };
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
message: `PR #${args.pr_number} 成功获取 ${images.length} 张图片(正文+评论)${failures.length ? `(${failures.length} 条失败/提示)` : ''}:`,
|
|
202
|
+
images,
|
|
203
|
+
failures: failures.length ? failures : undefined,
|
|
204
|
+
// 续传清单:触顶时 Agent 直接 image_urls: remaining 发起下一批
|
|
205
|
+
remaining,
|
|
206
|
+
};
|
|
137
207
|
}
|
|
138
208
|
case 'checks': {
|
|
139
209
|
const target = args.pr_number ? [String(args.pr_number)] : [];
|
|
140
|
-
|
|
210
|
+
// 字段集为 gh pr checks 的合法九字段子集(v2.50.0 引入 --json 起从未变更)。
|
|
211
|
+
// 旧字段集的 conclusion/detailsUrl 是 REST API 字段名,pr checks 从未支持——
|
|
212
|
+
// 初版误搬导致该 action 在所有 gh 版本上必然报 Unknown JSON field(Issue #3)。
|
|
213
|
+
// bucket 是 state 的分类视图(pass/fail/pending/skipping/cancel),link 即详情页。
|
|
214
|
+
const res = await executor.run(['pr', 'checks', ...target, ...repoArgs, '--json', 'name,state,bucket,link,workflow,startedAt,completedAt'], runOpts);
|
|
141
215
|
if (!res.ok)
|
|
142
216
|
throw new Error(res.error);
|
|
143
217
|
return normalizeOutput(res.data || res.rawOutput);
|
package/lib/tools/repo.d.ts
CHANGED
|
@@ -14,6 +14,17 @@ 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;
|
|
@@ -41,10 +52,7 @@ export declare function createRepoTool(executor: GhExecutor): {
|
|
|
41
52
|
schema: {
|
|
42
53
|
type: string;
|
|
43
54
|
};
|
|
44
|
-
render(args: any, value: any):
|
|
45
|
-
type: string;
|
|
46
|
-
text: string;
|
|
47
|
-
}[];
|
|
55
|
+
render(args: any, value: any): any[];
|
|
48
56
|
};
|
|
49
57
|
execute(args: any, execContext: any): Promise<Record<string, any>>;
|
|
50
58
|
};
|