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.
Files changed (44) hide show
  1. package/README.en.md +68 -22
  2. package/README.md +69 -23
  3. package/docs/UPGRADE-NOTES.md +93 -0
  4. package/docs/architecture.md +76 -31
  5. package/docs/images/1.png +0 -0
  6. package/docs/images/2.png +0 -0
  7. package/docs/images/3.png +0 -0
  8. package/docs/images/4.png +0 -0
  9. package/docs/npm-publish-guide.md +27 -5
  10. package/lib/api/routes-actions.d.ts +18 -4
  11. package/lib/api/routes-actions.js +143 -19
  12. package/lib/api/routes-lifecycle.d.ts +1 -1
  13. package/lib/api/routes-lifecycle.js +53 -2
  14. package/lib/api/routes-overview.js +104 -1
  15. package/lib/client.js +991 -55
  16. package/lib/commands/gh.js +11 -2
  17. package/lib/executor.d.ts +118 -1
  18. package/lib/executor.js +479 -12
  19. package/lib/main.js +10 -0
  20. package/lib/renderers/markdown.d.ts +12 -1
  21. package/lib/renderers/markdown.js +150 -16
  22. package/lib/tools/help.js +144 -11
  23. package/lib/tools/image-utils.d.ts +79 -0
  24. package/lib/tools/image-utils.js +247 -0
  25. package/lib/tools/issue.d.ts +18 -4
  26. package/lib/tools/issue.js +161 -6
  27. package/lib/tools/pr.d.ts +12 -4
  28. package/lib/tools/pr.js +128 -7
  29. package/lib/tools/repo.d.ts +20 -4
  30. package/lib/tools/repo.js +196 -4
  31. package/lib/tools/run.d.ts +8 -0
  32. package/lib/tools/run.js +104 -3
  33. package/lib/tools/secret.d.ts +5 -0
  34. package/lib/tools/secret.js +108 -23
  35. package/lib/types.d.ts +85 -1
  36. package/lib/update-settings.d.ts +18 -0
  37. package/lib/update-settings.js +60 -0
  38. package/lib/updater.d.ts +32 -1
  39. package/lib/updater.js +117 -9
  40. package/lib/upgrade-manager.d.ts +70 -1
  41. package/lib/upgrade-manager.js +346 -46
  42. package/lib/version.d.ts +1 -1
  43. package/lib/version.js +1 -1
  44. 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 ![alt](url) 两种形态
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
+ }
@@ -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;
@@ -14,6 +15,15 @@ export declare function createIssueTool(executor: GhExecutor): {
14
15
  type: string;
15
16
  description: string;
16
17
  };
18
+ target_repo: {
19
+ type: string;
20
+ description: string;
21
+ };
22
+ lock_reason: {
23
+ type: string;
24
+ enum: string[];
25
+ description: string;
26
+ };
17
27
  repo: {
18
28
  type: string;
19
29
  description: string;
@@ -71,6 +81,13 @@ export declare function createIssueTool(executor: GhExecutor): {
71
81
  type: string;
72
82
  description: string;
73
83
  };
84
+ image_urls: {
85
+ type: string;
86
+ items: {
87
+ type: string;
88
+ };
89
+ description: string;
90
+ };
74
91
  };
75
92
  required: string[];
76
93
  };
@@ -78,10 +95,7 @@ export declare function createIssueTool(executor: GhExecutor): {
78
95
  schema: {
79
96
  type: string;
80
97
  };
81
- render(args: any, value: any): {
82
- type: string;
83
- text: string;
84
- }[];
98
+ render(args: any, value: any): any[];
85
99
  };
86
100
  execute(args: any, execContext: any): Promise<Record<string, any>>;
87
101
  };
@@ -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,12 +20,21 @@ 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', 'delete', 'transfer', 'pin', 'unpin', 'lock', 'unlock', 'status'],
24
+ description: 'Issue 操作类型。view_images 下载正文内的图片到本地并返回可渲染的图片内容块(Agent 可直接"看"用户报障截图)。delete 物理删除不可恢复、transfer 跨仓库迁移,两者均需 confirm: true。',
22
25
  },
23
26
  issue_number: {
24
27
  type: 'number',
25
- description: 'Issue 编号(view, comment, close, reopen, edit, develop 时使用)。',
28
+ description: 'Issue 编号(view, comment, close, reopen, edit, develop, delete, transfer, pin/unpin/lock/unlock 时使用)。',
29
+ },
30
+ target_repo: {
31
+ type: 'string',
32
+ description: 'transfer 动作必填:迁移目标仓库 [owner/repo](迁移后原 URL 301 重定向,归属不可静默撤回)。',
33
+ },
34
+ lock_reason: {
35
+ type: 'string',
36
+ enum: ['off_topic', 'resolved', 'spam', 'too_heated'],
37
+ description: 'lock 动作可选:锁定原因(gh 实测四合法值)。',
26
38
  },
27
39
  repo: {
28
40
  type: 'string',
@@ -71,7 +83,12 @@ export function createIssueTool(executor) {
71
83
  },
72
84
  confirm: {
73
85
  type: 'boolean',
74
- description: '高危破坏性操作确认标志(close 动作必需)。若为 false 则仅输出待关闭警告。',
86
+ description: '高危破坏性操作确认标志(close/delete/transfer 动作必需)。delete 物理删除不可恢复、transfer 跨仓库迁移,拦截级别高于 close。',
87
+ },
88
+ image_urls: {
89
+ type: 'array',
90
+ items: { type: 'string' },
91
+ description: 'view_images 可选:仅下载指定 URL 的图片(从 view 返回的 images 清单中选取,不在清单内的会被拒绝)。留空下载全部(正文+评论)。',
75
92
  },
76
93
  },
77
94
  required: ['action'],
@@ -79,7 +96,9 @@ export function createIssueTool(executor) {
79
96
  output: {
80
97
  schema: { type: 'object' },
81
98
  render(args, value) {
82
- return [{ type: 'text', text: renderIssueMarkdown(args, value) }];
99
+ // view_images 的图片以宿主 image 内容块输出:模型与人类都能直接看到图,
100
+ // base64 会污染上下文(一张截图 ~88KB),引用落盘文件由宿主按需加载
101
+ return [{ type: 'text', text: renderIssueMarkdown(args, value) }, ...buildImageBlocks(value)];
83
102
  },
84
103
  },
85
104
  async execute(args, execContext) {
@@ -126,7 +145,68 @@ export function createIssueTool(executor) {
126
145
  const res = await executor.run(['issue', 'view', String(args.issue_number), ...repoArgs, '--json', 'number,title,body,state,author,labels,assignees,comments,url'], runOpts);
127
146
  if (!res.ok)
128
147
  throw new Error(res.error);
129
- return normalizeOutput(res.data || res.rawOutput);
148
+ const out = normalizeOutput(res.data || res.rawOutput);
149
+ // 附带图片 URL 清单(正文+评论,与 view_images 提取范围同源):
150
+ // Agent 按正文里的原始 URL 决策,需要看图时用 image_urls 精准选取
151
+ const bodies = [typeof out.body === 'string' ? out.body : ''];
152
+ if (Array.isArray(out.comments)) {
153
+ for (const c of out.comments) {
154
+ if (c && typeof c.body === 'string')
155
+ bodies.push(c.body);
156
+ }
157
+ }
158
+ out.images = Array.from(new Set(bodies.flatMap((b) => extractImageUrls(b))));
159
+ return out;
160
+ }
161
+ case 'view_images': {
162
+ if (!args.issue_number)
163
+ throw new Error('view_images 操作必须提供 issue_number');
164
+ // 第一步:取正文+评论提取图片 URL(与 view 清单同源)
165
+ const detail = await executor.run(['issue', 'view', String(args.issue_number), ...repoArgs, '--json', 'body,comments'], runOpts);
166
+ if (!detail.ok)
167
+ throw new Error(detail.error);
168
+ const d = detail.data && typeof detail.data === 'object' ? detail.data : {};
169
+ const bodies = [typeof d.body === 'string' ? d.body : ''];
170
+ if (Array.isArray(d.comments)) {
171
+ for (const c of d.comments) {
172
+ if (c && typeof c.body === 'string')
173
+ bodies.push(c.body);
174
+ }
175
+ }
176
+ const extracted = Array.from(new Set(bodies.flatMap((b) => extractImageUrls(b))));
177
+ if (extracted.length === 0) {
178
+ return { message: `Issue #${args.issue_number} 正文与评论中未发现图片附件。` };
179
+ }
180
+ // 第二步:目标选择——不传 image_urls 下载全部;传入则与正文清单
181
+ // 严格交集(防幻觉/诱导的任意 URL 下载),空数组视为误传明确报错
182
+ let targets = extracted;
183
+ const rejections = [];
184
+ if (args.image_urls !== undefined) {
185
+ if (!Array.isArray(args.image_urls) || args.image_urls.length === 0) {
186
+ throw new Error('image_urls 为空数组:如需下载全部请省略该参数,如需指定请传入 view 返回清单中的 URL。');
187
+ }
188
+ const filtered = filterRequestedUrls(args.image_urls, extracted);
189
+ targets = filtered.matched;
190
+ for (const bad of filtered.rejected) {
191
+ rejections.push(`${String(bad).slice(-24)}: 不在本 Issue 图片清单中,已拒绝(防任意下载)`);
192
+ }
193
+ }
194
+ // 第三步:共享下载执行体(gh api 二进制通道 + 魔数校验 + 字节护栏 + 内容寻址落盘)
195
+ const { images, failures: dlFailures, remaining } = await downloadImages(targets, (url) => executor.run(['api', url], { ...runOpts, binary: true, timeoutMs: 30_000 }));
196
+ const failures = [...rejections, ...dlFailures];
197
+ if (images.length === 0 && rejections.length === 0 && !remaining) {
198
+ return { message: `Issue #${args.issue_number} 的图片全部下载失败:`, failures };
199
+ }
200
+ if (images.length === 0) {
201
+ return { message: `Issue #${args.issue_number} 未能获取任何图片(请求 ${targets.length} 张,被拒 ${rejections.length} 张)`, failures, remaining };
202
+ }
203
+ return {
204
+ message: `Issue #${args.issue_number} 成功获取 ${images.length} 张图片${failures.length ? `(${failures.length} 条失败/提示)` : ''}:`,
205
+ images,
206
+ failures: failures.length ? failures : undefined,
207
+ // 续传清单:触顶时 Agent 直接 image_urls: remaining 发起下一批
208
+ remaining,
209
+ };
130
210
  }
131
211
  case 'create': {
132
212
  const cmd = ['issue', 'create', ...repoArgs, '--title', args.title || '', '--body', args.body || ''];
@@ -206,6 +286,81 @@ export function createIssueTool(executor) {
206
286
  throw new Error(res.error);
207
287
  return { message: `Issue #${args.issue_number} 已重新开启` };
208
288
  }
289
+ // ---- 变更面新增(S3)----
290
+ case 'pin':
291
+ case 'unpin': {
292
+ if (!args.issue_number)
293
+ throw new Error(`${args.action} 操作必须提供 issue_number`);
294
+ // 置顶状态位:可逆(pin↔unpin 互逆),按轻变更免 confirm
295
+ const res = await executor.run(['issue', args.action, String(args.issue_number), ...repoArgs], runOpts);
296
+ if (!res.ok)
297
+ throw new Error(res.error);
298
+ return { message: `Issue #${args.issue_number} 已${args.action === 'pin' ? '置顶' : '取消置顶'}` };
299
+ }
300
+ case 'lock':
301
+ case 'unlock': {
302
+ if (!args.issue_number)
303
+ throw new Error(`${args.action} 操作必须提供 issue_number`);
304
+ // unlock 是 gh 独立命令(非 lock 的 flag,实测 help 已钉);锁定对话可逆,免 confirm
305
+ const cmd = ['issue', args.action, String(args.issue_number), ...repoArgs];
306
+ if (args.action === 'lock' && args.lock_reason) {
307
+ cmd.push('--reason', args.lock_reason);
308
+ }
309
+ const res = await executor.run(cmd, runOpts);
310
+ if (!res.ok)
311
+ throw new Error(res.error);
312
+ return {
313
+ message: `Issue #${args.issue_number} 对话已${args.action === 'lock' ? '锁定' : '解锁'}`,
314
+ ...(args.action === 'lock' && args.lock_reason ? { reason: args.lock_reason } : {}),
315
+ };
316
+ }
317
+ case 'delete': {
318
+ if (!args.issue_number)
319
+ throw new Error('delete 操作必须提供 issue_number');
320
+ // 物理删除 > close 的拦截级别:issue 编号永久消失、评论湮灭、GraphQL 无法恢复
321
+ if (args.confirm !== true) {
322
+ return {
323
+ status: 'AWAITING_CONFIRMATION',
324
+ warning: `⚠️ 高危操作拦截:你即将**物理删除** Issue #${args.issue_number}(非 close,不可恢复)。`,
325
+ impact: 'Issue 编号将永久消失,全部评论与附件随之湮灭,GraphQL 层无法恢复——这与 close(可 reopen 逆转)有本质区别。',
326
+ requiredAction: '如确认删除,请与用户核实后附加 `confirm: true` 重新调用。',
327
+ };
328
+ }
329
+ // 非 TTY 下 gh issue delete 会挂起等待 prompt,--yes 是非交互执行的唯一途径
330
+ const res = await executor.run(['issue', 'delete', String(args.issue_number), ...repoArgs, '--yes'], runOpts);
331
+ if (!res.ok)
332
+ throw new Error(res.error);
333
+ return { message: `Issue #${args.issue_number} 已永久删除(不可恢复)` };
334
+ }
335
+ case 'transfer': {
336
+ if (!args.issue_number)
337
+ throw new Error('transfer 操作必须提供 issue_number');
338
+ if (!args.target_repo)
339
+ throw new Error('transfer 操作必须提供 target_repo [owner/repo]');
340
+ if (args.target_repo === repoTarget) {
341
+ throw new Error(`transfer 目标仓库 (${args.target_repo}) 与当前仓库相同,迁移无意义。`);
342
+ }
343
+ // 跨仓库迁移:原 URL 301 但归属改变,不可静默撤回——与 delete 同级 confirm
344
+ if (args.confirm !== true) {
345
+ return {
346
+ status: 'AWAITING_CONFIRMATION',
347
+ warning: `⚠️ 高危操作拦截:你即将把 Issue #${args.issue_number} 从 ${repoTarget} 迁移到 ${args.target_repo}。`,
348
+ impact: `迁移后 Issue 归属 ${args.target_repo}(原 URL 301 重定向),标签/里程碑不随迁,操作不可静默撤回。需要目标仓库的写权限。`,
349
+ requiredAction: '如确认迁移,请与用户核实后附加 `confirm: true` 重新调用。',
350
+ };
351
+ }
352
+ const res = await executor.run(['issue', 'transfer', String(args.issue_number), args.target_repo, ...repoArgs], runOpts);
353
+ if (!res.ok)
354
+ throw new Error(res.error);
355
+ return { message: `Issue #${args.issue_number} 已迁移至 ${args.target_repo}`, url: res.rawOutput };
356
+ }
357
+ case 'status': {
358
+ // 非 TTY 纯文本分段(Assigned/Mentioning/Pushed),gh 原生已是紧凑格式
359
+ const res = await executor.run(['issue', 'status', ...repoArgs], { ...runOpts, rawText: true, timeoutMs: 20_000 });
360
+ if (!res.ok)
361
+ throw new Error(res.error);
362
+ return { result: res.rawOutput || '无分配议题' };
363
+ }
209
364
  default:
210
365
  throw new Error(`未知的 Issue 操作: ${args.action}`);
211
366
  }
package/lib/tools/pr.d.ts CHANGED
@@ -52,6 +52,10 @@ export declare function createPrTool(executor: GhExecutor): {
52
52
  type: string;
53
53
  description: string;
54
54
  };
55
+ undo: {
56
+ type: string;
57
+ description: string;
58
+ };
55
59
  state: {
56
60
  type: string;
57
61
  enum: string[];
@@ -77,6 +81,13 @@ export declare function createPrTool(executor: GhExecutor): {
77
81
  type: string;
78
82
  description: string;
79
83
  };
84
+ image_urls: {
85
+ type: string;
86
+ items: {
87
+ type: string;
88
+ };
89
+ description: string;
90
+ };
80
91
  };
81
92
  required: string[];
82
93
  };
@@ -84,10 +95,7 @@ export declare function createPrTool(executor: GhExecutor): {
84
95
  schema: {
85
96
  type: string;
86
97
  };
87
- render(args: any, value: any): {
88
- type: string;
89
- text: string;
90
- }[];
98
+ render(args: any, value: any): any[];
91
99
  };
92
100
  execute(args: any, execContext: any): Promise<Record<string, any>>;
93
101
  };