dsh-github-flow 0.7.0 → 0.7.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/lib/tools/help.js CHANGED
@@ -27,9 +27,10 @@ const KNOWLEDGE_BASE = {
27
27
  | \`github_help\` | 路由导航、官方语法速查与故障自愈 | 遇到不确定的操作、搜索语法或接口报错时查询用法与诊断配方 |
28
28
 
29
29
  💡 **使用准则**:优先调用高层语义工具(pr / issue / run / repo / release / secret);当高层工具无法满足特殊接口需求时,再降级使用 \`github_api\`。
30
+ 📄 **长正文走文件,别塞进 body**:正文/发版说明/请求体/prompt 若已经写成本地 md 文件,直接用 \`body_file\` / \`notes_file\` / \`input_file\` / \`prompt_file\` 指向它,不要把全文内联——插件读文件、正文经 stdin 交给 gh,既不占模型上下文,也不会撞上 Windows 的命令行长度上限。
30
31
  📊 **规模口径**:19 个工具 / 106 个业务 action(不含 \`github_help\` 的 22 个查询主题与 \`github_api\` 的 4 个 HTTP method——它们不是业务动作)。
31
32
  💬 **人类指令**:聊天框支持 \`/gh status\`(大盘看板)、\`/gh upgrade\`(一键升级插件)、\`/gh help\`(快捷帮助)。
32
- 🖥️ **可视化工作台**:最左侧 Octocat 图标打开全景驾驶舱(4 标签:本地工作区 / 我的仓库 / Pull Requests / 待办 Issues);每个会话右侧栏有 5 个 Tab(Git / 未提交 / PR / Issues / Actions)。PR、Issue、Actions 三类卡片点击均可在线查看详情(Actions 为 job / step 树 + 按需失败日志),无需跳浏览器。
33
+ 🖥️ **可视化工作台**:最左侧 Octocat 图标打开全景驾驶舱(4 标签:本地工作区 / 我的仓库 / Pull Requests / 待办 Issues);每个会话右侧栏有 5 个 Tab(Git / 未提交 / PR / Issues / Actions)。PR、Issue、Actions 三类卡片点击均可在线查看详情(Actions 为 job / step 树 + 按需失败日志),无需跳浏览器;提交详情里的「文件变更统计」与未提交清单里的文件均可弹窗预览该文件的 Unified Diff(整包补丁按文件切分、重命名路径自动归一、超长补丁按文件边界截断)。
33
34
  🗺️ **命令对照**:不确定某 gh 命令对应哪个工具?\`github_help(topic: 'mapping')\` 查询完整映射表(含未覆盖域的 API 引导)。`,
34
35
  pr: `### 🔀 github_pr 详细操作指南
35
36
 
@@ -52,6 +53,8 @@ const KNOWLEDGE_BASE = {
52
53
  - 将基准分支最新提交同步进 PR 分支,解决「分支落后需更新」的 CI 门禁失败。
53
54
  - **上传本地附件/图片**: \`github_pr(action: 'create', title: 'UI 重构', body: '对比图如下', attach_images: ['./before.png', './after.png'])\`
54
55
  - 需 gh ≥ 2.99;支持 create/edit/comment 动作,单批上限 50 个文件,禁止网络 URL,与 view_images 对称闭环。
56
+ - **长正文走本地文件**: \`github_pr(action: 'create', title: '重构方案', body_file: './PR_BODY.md')\`
57
+ - \`body_file\` 与 \`body\` 二选一,适用于 create/edit/comment/review 四个动作(review 的行级评注走 REST,正文同样可由文件提供);相对路径按当前工作区解析,文件必须是合法 UTF-8(GBK/ANSI 会明确报错),正文上限 65,536 字符;插件读文件后经 \`--body-file -\` 走 stdin,正文不进模型上下文。
55
58
  - **追加评论与编辑**: \`github_pr(action: 'comment', pr_number: 123, body: 'LGTM'); github_pr(action: 'edit', pr_number: 123, body: '更新后的描述')\`
56
59
  - 两者同样支持 \`attach_images\` 附件通道。`,
57
60
  issue: `### 🎯 github_issue 详细操作指南
@@ -65,6 +68,8 @@ const KNOWLEDGE_BASE = {
65
68
  - 触达目录上限时返回 \`remaining\` 结构化清单,直接作为下一批 image_urls 续传即可。
66
69
  - **上传本地附件/图片**: \`github_issue(action: 'comment', issue_number: 42, body: '复现证据', attach_images: ['./bug.png'])\`
67
70
  - 需 gh ≥ 2.99;支持 create/edit/comment 动作,单批上限 50 个本地文件(绝对路径或工作区相对路径,禁止网络 URL)。
71
+ - **长正文走本地文件**: \`github_issue(action: 'create', title: '周报:本周修复清单', body_file: './report.md')\`
72
+ - \`body_file\` 与 \`body\` 二选一(同时给出会直接报错,不做静默优先级);相对路径按当前工作区解析,文件必须是合法 UTF-8(GBK/ANSI 会明确报错),正文上限 65,536 字符;插件读文件后经 \`--body-file -\` 走 stdin,正文不进模型上下文。
68
73
  - **关联开发分支**: \`github_issue(action: 'develop', issue_number: 42, branch_name: 'fix-login-bug')\`
69
74
  - 自动在远端和本地创建带有官方关联关系的开发分支并切换过去,实现“读需求 -> 开分支写代码”的无缝衔接。
70
75
  - **编辑 Issue**: \`github_issue(action: 'edit', issue_number: 42, title: '新标题', add_labels: ['p1'], remove_labels: ['triage'])\`
@@ -125,6 +130,9 @@ const KNOWLEDGE_BASE = {
125
130
  - 附加 \`Accept: application/vnd.github.raw\`,返回纯文本而非 base64 包装。
126
131
  - **全量聚合翻页**:
127
132
  \`github_api(endpoint: 'repos/owner/repo/commits', paginate: true)\`
133
+ - **长请求体走本地文件**:
134
+ \`github_api(endpoint: 'repos/owner/repo/issues', method: 'POST', input_file: './payload.json')\`
135
+ - 映射 \`--input -\`(插件读文件后经 stdin 交给 gh,省去把 JSON 塞进参数);与 \`fields\` **不互斥**:两者同时给出时 \`fields\` 作为 URL 查询参数、文件内容作为请求体;请求体不是 GitHub 正文,不受 65,536 字符的正文口径约束。
128
136
  - **GraphQL 请求**:
129
137
  \`github_api(endpoint: 'graphql', fields: { query: 'query { viewer { login } }' })\``,
130
138
  release: `### 🏷️ github_release 版本发布指南
@@ -134,6 +142,8 @@ const KNOWLEDGE_BASE = {
134
142
  - **创建 Release(自动生成说明)**:
135
143
  \`github_release(action: 'create', tag: 'v0.1.9', generate_notes: true, prerelease: false)\`
136
144
  - 若远端尚无该 Tag,gh 会自动从 target 分支(默认主分支)最新提交打 Tag。
145
+ - **用本地 md 作为发版说明**: \`github_release(action: 'create', tag: 'v0.1.9', notes_file: './RELEASE_NOTES.md')\`
146
+ - \`notes_file\` 与 \`notes\` 二选一(映射 \`--notes-file -\`);相对路径按当前工作区解析,文件必须是合法 UTF-8、不超过 4MB;**发版说明不套用正文的 65,536 字符口径**(GitHub 对 Release 说明的上限宽得多),长说明写进文件即可,不必先读进上下文再原样回填。
137
147
  - **上传构建资产**:
138
148
  \`github_release(action: 'upload', tag: 'v0.1.9', assets: ['./dsh-github-flow-0.1.9.tgz'])\`
139
149
  - 覆盖同名旧资产必须附加 \`clobber: true, confirm: true\`(旧资产删除后不可恢复)。
@@ -255,6 +265,8 @@ const KNOWLEDGE_BASE = {
255
265
  - **检视讨论正文与回复楼层**: \`github_discussion(action: 'view', discussion_number: 123, comments: true)\`
256
266
  - **发起新讨论**: \`github_discussion(action: 'create', title: 'RFC: 架构演进方案', category: 'Ideas', body: '详细方案...')\`
257
267
  - \`title\`、\`category\` 与 \`body\` 均为必填项。
268
+ - **长正文走本地文件**: \`github_discussion(action: 'create', title: 'RFC: 架构演进方案', category: 'Ideas', body_file: './rfc.md')\`
269
+ - \`body_file\` 与 \`body\` 二选一(\`title\` / \`category\` 仍必填,映射 \`--body-file -\`);相对路径按当前工作区解析,文件必须是合法 UTF-8,正文上限 65,536 字符;RFC 动辄数千字,写进文件比塞进参数更省上下文。
258
270
  - **编辑讨论**: \`github_discussion(action: 'edit', discussion_number: 123, title: '新标题', body: '修正后的正文')\`
259
271
  - **发表讨论回复**: \`github_discussion(action: 'comment', discussion_number: 123, body: '赞同该方案!')\`
260
272
  - 💡 **提示**:若报错「discussions disabled」,说明目标仓库未在 GitHub 仓库「Settings -> General -> Features」中启用 Discussions 社区功能。`,
@@ -289,7 +301,7 @@ const KNOWLEDGE_BASE = {
289
301
  - **审一个 PR**: \`github_task(action: 'review_pr', pr_number: 123)\`
290
302
  - 一次拿齐:PR 元数据 + head SHA + 变动文件 diff(按 \`budget_chars\` 裁剪)+ CI 结论 + 仓库 \`AGENTS.md\` 审查规则 + 既有行级评论数。
291
303
  - \`focus: 'security' | 'correctness' | 'perf' | 'style'\` 只影响 summary 的提示语,不改变取数。
292
- - **写回**(默认关闭): \`github_task(action: 'review_pr', pr_number: 123, write: true, confirm: true, body: '…', comments: [{path, line, body}])\`,字段语义与 \`github_pr(action: 'review')\` 完全一致。
304
+ - **写回**(默认关闭): \`github_task(action: 'review_pr', pr_number: 123, write: true, confirm: true, body: '…', comments: [{path, line, body}])\`,字段语义与 \`github_pr(action: 'review')\` 完全一致;整体审阅说明若已写成本地 md,改用 \`body_file\`(与 \`body\` 二选一,写回走 REST 请求体)。
293
305
  - **诊断一次 CI 失败**: \`github_task(action: 'diagnose_ci', run_id: 456)\` 或 \`github_task(action: 'diagnose_ci', pr_number: 123)\`
294
306
  - 定位失败 job/step → 萃取失败日志锚点(pytest / node / Go / tsc / npm)→ 汇总疑似相关文件。
295
307
  - 日志被截断时 \`truncated.totalUnknown = true\`:**不要据此判断「就这些失败」**。
@@ -452,6 +464,7 @@ const KNOWLEDGE_BASE = {
452
464
  - 人类侧在线查看(PR / Issue / Actions Run 详情弹窗)由插件 HTTP 路由提供(\`/api/github/pr-detail\`、\`/api/github/issue-detail\`、\`/api/github/run-detail\`、\`/api/github/run-logs\`),**不是工具 action**;Agent 侧对应能力分别是 \`github_pr(action:'view')\`、\`github_issue(action:'view')\`、\`github_run(action:'view')\`
453
465
  - 图片查看:\`github_issue\`/\`github_pr\` 的 \`view_images\`、\`github_repo\` 的 \`readme\` + image_urls(插件独有能力,gh 无对应命令)
454
466
  - 附件上传:\`github_issue\`/\`github_pr\` 的 \`attach_images\` 映射 gh ≥2.99 原生可重复 \`--attach\` flag(支持 create/edit/comment)
467
+ - 本地文件正文:\`gh issue create -F report.md\` 的等价写法是 \`github_issue(action:'create', body_file:'report.md')\`——「本地 md → Issue/PR/讨论/发版说明/请求体/任务 prompt」这条通道已由插件覆盖,文件由插件读取、正文经 stdin 交给 gh,**不经过模型上下文**;对应参数分别是 \`body_file\`(issue / pr / discussion / task)、\`notes_file\`(release)、\`input_file\`(api)、\`prompt_file\`(agent_task),相对路径按当前工作区解析
455
468
 
456
469
  #### 三、未覆盖域引导(经 github_api 调用)
457
470
 
@@ -36,6 +36,10 @@ export declare function createIssueTool(executor: GhExecutor): {
36
36
  type: string;
37
37
  description: string;
38
38
  };
39
+ body_file: {
40
+ type: string;
41
+ description: string;
42
+ };
39
43
  labels: {
40
44
  type: string;
41
45
  items: {
@@ -3,6 +3,7 @@ import { compareGhVersions } from '../executor.js';
3
3
  import { renderIssueMarkdown } from '../renderers/markdown.js';
4
4
  import { extractImageUrls, downloadImages, buildImageBlocks, filterRequestedUrls, validateAttachPaths } from './image-utils.js';
5
5
  import { assertPositionArg } from '../executor/args-guard.js';
6
+ import { resolveBodySource } from './body-input.js';
6
7
  function normalizeOutput(data) {
7
8
  if (data && typeof data === 'object' && !Array.isArray(data)) {
8
9
  return data;
@@ -49,7 +50,11 @@ export function createIssueTool(executor) {
49
50
  },
50
51
  body: {
51
52
  type: 'string',
52
- description: 'Issue 描述正文或追加评论内容(create, comment, edit 时使用)。',
53
+ description: 'Issue 描述正文或追加评论内容(create, comment, edit 时使用)。与 body_file 二选一。',
54
+ },
55
+ body_file: {
56
+ type: 'string',
57
+ description: '正文来源:本地 Markdown 文件路径(相对路径按当前工作区解析)。与 body 二选一;长正文用本参数,无需把全文读进上下文。',
53
58
  },
54
59
  labels: {
55
60
  type: 'array',
@@ -125,6 +130,8 @@ export function createIssueTool(executor) {
125
130
  }
126
131
  const repoArgs = ['-R', repoTarget];
127
132
  const runOpts = { cwd, sessionId };
133
+ // 正文来自本地文件时经 stdin 注入(`--body-file -`):绕开 Windows 约 32K 的命令行上限
134
+ const stdinPatch = (body) => (body.stdin === undefined ? {} : { stdin: body.stdin });
128
135
  const appendAttachFlags = async (cmd, attachImages) => {
129
136
  const attachPaths = validateAttachPaths(attachImages, cwd);
130
137
  if (attachPaths.length > 0) {
@@ -230,7 +237,8 @@ export function createIssueTool(executor) {
230
237
  };
231
238
  }
232
239
  case 'create': {
233
- const cmd = ['issue', 'create', ...repoArgs, '--title', args.title || '', '--body', args.body || ''];
240
+ const body = resolveBodySource({ inline: args.body, file: args.body_file }, { cwd, keepEmptyInline: true });
241
+ const cmd = ['issue', 'create', ...repoArgs, '--title', args.title || '', ...body.argv];
234
242
  if (args.labels && Array.isArray(args.labels) && args.labels.length > 0) {
235
243
  cmd.push('--label', args.labels.join(','));
236
244
  }
@@ -238,7 +246,7 @@ export function createIssueTool(executor) {
238
246
  cmd.push('--assignee', args.assignees.join(','));
239
247
  }
240
248
  await appendAttachFlags(cmd, args.attach_images);
241
- const res = await executor.run(cmd, runOpts);
249
+ const res = await executor.run(cmd, { ...runOpts, ...stdinPatch(body) });
242
250
  if (!res.ok)
243
251
  throw new Error(res.error);
244
252
  return { message: 'Issue 创建成功', url: res.rawOutput };
@@ -246,11 +254,11 @@ export function createIssueTool(executor) {
246
254
  case 'edit': {
247
255
  if (!args.issue_number)
248
256
  throw new Error('edit 操作必须提供 issue_number');
257
+ const body = resolveBodySource({ inline: args.body, file: args.body_file }, { cwd });
249
258
  const editCmd = ['issue', 'edit', String(args.issue_number), ...repoArgs];
250
259
  if (args.title)
251
260
  editCmd.push('--title', args.title);
252
- if (args.body)
253
- editCmd.push('--body', args.body);
261
+ editCmd.push(...body.argv);
254
262
  if (args.add_labels && Array.isArray(args.add_labels) && args.add_labels.length > 0) {
255
263
  for (const l of args.add_labels)
256
264
  editCmd.push('--add-label', l);
@@ -260,7 +268,7 @@ export function createIssueTool(executor) {
260
268
  editCmd.push('--remove-label', l);
261
269
  }
262
270
  await appendAttachFlags(editCmd, args.attach_images);
263
- const res = await executor.run(editCmd, runOpts);
271
+ const res = await executor.run(editCmd, { ...runOpts, ...stdinPatch(body) });
264
272
  if (!res.ok)
265
273
  throw new Error(res.error);
266
274
  return { message: `Issue #${args.issue_number} 更新成功`, output: res.rawOutput };
@@ -287,11 +295,14 @@ export function createIssueTool(executor) {
287
295
  return { message: `已成功为 Issue #${args.issue_number} 创建并检出开发分支`, output: res.rawOutput };
288
296
  }
289
297
  case 'comment': {
290
- if (!args.issue_number || !args.body)
291
- throw new Error('comment 必须提供 issue_number 和 body');
292
- const commentCmd = ['issue', 'comment', String(args.issue_number), ...repoArgs, '--body', args.body];
298
+ if (!args.issue_number)
299
+ throw new Error('comment 操作必须提供 issue_number');
300
+ const body = resolveBodySource({ inline: args.body, file: args.body_file }, { cwd });
301
+ if (body.chars === 0)
302
+ throw new Error('comment 必须提供 body 或 body_file(评论正文)');
303
+ const commentCmd = ['issue', 'comment', String(args.issue_number), ...repoArgs, ...body.argv];
293
304
  await appendAttachFlags(commentCmd, args.attach_images);
294
- const res = await executor.run(commentCmd, runOpts);
305
+ const res = await executor.run(commentCmd, { ...runOpts, ...stdinPatch(body) });
295
306
  if (!res.ok)
296
307
  throw new Error(res.error);
297
308
  return { message: '评论发布成功', output: res.rawOutput };
package/lib/tools/pr.d.ts CHANGED
@@ -27,6 +27,10 @@ export declare function createPrTool(executor: GhExecutor): {
27
27
  type: string;
28
28
  description: string;
29
29
  };
30
+ body_file: {
31
+ type: string;
32
+ description: string;
33
+ };
30
34
  base: {
31
35
  type: string;
32
36
  description: string;
package/lib/tools/pr.js CHANGED
@@ -4,6 +4,7 @@ import { renderPrMarkdown } from '../renderers/markdown.js';
4
4
  import { assertPositionArg } from '../executor/args-guard.js';
5
5
  import { extractImageUrls, downloadImages, buildImageBlocks, filterRequestedUrls, validateAttachPaths } from './image-utils.js';
6
6
  import { readReviewRules, summarizeSeverity } from '../review-rules.js';
7
+ import { resolveBodySource } from './body-input.js';
7
8
  function normalizeOutput(data) {
8
9
  if (data && typeof data === 'object' && !Array.isArray(data)) {
9
10
  return data;
@@ -41,7 +42,11 @@ export function createPrTool(executor) {
41
42
  },
42
43
  body: {
43
44
  type: 'string',
44
- description: '创建 PR 或提交 Review 时的内容描述。',
45
+ description: '创建 PR 或提交 Review 时的内容描述(create, comment, edit, review 时使用)。与 body_file 二选一。',
46
+ },
47
+ body_file: {
48
+ type: 'string',
49
+ description: '正文来源:本地 Markdown 文件路径(相对路径按当前工作区解析)。与 body 二选一;长正文用本参数,无需把全文读进上下文。',
45
50
  },
46
51
  base: {
47
52
  type: 'string',
@@ -166,6 +171,8 @@ export function createPrTool(executor) {
166
171
  }
167
172
  const repoArgs = ['-R', repoTarget];
168
173
  const runOpts = { cwd, sessionId };
174
+ // 正文来自本地文件时经 stdin 注入(`--body-file -`):绕开 Windows 约 32K 的命令行上限
175
+ const stdinPatch = (body) => (body.stdin === undefined ? {} : { stdin: body.stdin });
169
176
  const appendAttachFlags = async (cmd, attachImages) => {
170
177
  const attachPaths = validateAttachPaths(attachImages, cwd);
171
178
  if (attachPaths.length > 0) {
@@ -373,7 +380,10 @@ export function createPrTool(executor) {
373
380
  return normalizeOutput(res.data || res.rawOutput);
374
381
  }
375
382
  case 'create': {
376
- const createCmd = ['pr', 'create', ...repoArgs, '--title', args.title || '', '--body', args.body || ''];
383
+ // keepEmptyInline:无正文时仍要下发 `--body ''`,否则 gh 会打开编辑器等交互输入,
384
+ // 在非 TTY 环境下直接挂起。正文来源与位置保持在 `--title` 之后,老调用 argv 逐字节不变。
385
+ const body = resolveBodySource({ inline: args.body, file: args.body_file }, { cwd, keepEmptyInline: true });
386
+ const createCmd = ['pr', 'create', ...repoArgs, '--title', args.title || '', ...body.argv];
377
387
  if (args.base)
378
388
  createCmd.push('--base', args.base);
379
389
  // 跨 fork 提 PR 必须显式给出 --head(形如 forkOwner:branch)。
@@ -387,7 +397,7 @@ export function createPrTool(executor) {
387
397
  if (dryRun)
388
398
  createCmd.push('--dry-run');
389
399
  await appendAttachFlags(createCmd, args.attach_images);
390
- const res = await executor.run(createCmd, runOpts);
400
+ const res = await executor.run(createCmd, { ...runOpts, ...stdinPatch(body) });
391
401
  if (!res.ok)
392
402
  throw new Error(res.error);
393
403
  return dryRun
@@ -397,6 +407,12 @@ export function createPrTool(executor) {
397
407
  case 'review': {
398
408
  if (!args.pr_number)
399
409
  throw new Error('review 操作必须提供 pr_number');
410
+ /*
411
+ * 正文在分支判定之前解析:带 comments 时走的是 REST reviews 通道,那条路没有任何
412
+ * `--body-file` 可用,正文只能由插件读出来塞进 JSON。若把解析放在 gh 分支里,
413
+ * 「带 comments + body_file」就会静默丢正文——所以两条通道共用这一份结果。
414
+ */
415
+ const body = resolveBodySource({ inline: args.body, file: args.body_file }, { cwd });
400
416
  /*
401
417
  * 行级评注分支:只有显式传了非空 comments 才走 REST reviews 通道。
402
418
  * 不传 → 下面的原 gh pr review 路径一字不动(默认行为零变化,这是本改动的
@@ -406,7 +422,8 @@ export function createPrTool(executor) {
406
422
  if (lineComments.length > 0) {
407
423
  const res = await executor.createPrReview(cwd, args.repo, args.pr_number, {
408
424
  event: args.review_decision,
409
- body: args.body,
425
+ // createPrReview 侧按 trim 归一,空串与 undefined 等价,直接给权威正文即可
426
+ body: body.text,
410
427
  comments: lineComments,
411
428
  commit_id: args.commit_id,
412
429
  });
@@ -419,7 +436,7 @@ export function createPrTool(executor) {
419
436
  * 已提交正文统计等价;而在工具层算不必让 executor 回传审计字段。
420
437
  * 目的是让调用方一眼看到「这轮意见里有没有 P0」,而不是自己数。
421
438
  */
422
- const severity = summarizeSeverity([args.body, ...lineComments.map((c) => (c && typeof c.body === 'string' ? c.body : ''))].filter((b) => typeof b === 'string' && b.trim()));
439
+ const severity = summarizeSeverity([body.text, ...lineComments.map((c) => (c && typeof c.body === 'string' ? c.body : ''))].filter((b) => typeof b === 'string' && b.trim()));
423
440
  const lines = [
424
441
  res.degraded
425
442
  ? `⚠️ Review 已提交(行级评注降级为正文段落)`
@@ -452,9 +469,9 @@ export function createPrTool(executor) {
452
469
  reviewCmd.push('--request-changes');
453
470
  else
454
471
  reviewCmd.push('--comment');
455
- if (args.body)
456
- reviewCmd.push('--body', args.body);
457
- const res = await executor.run(reviewCmd, runOpts);
472
+ // 位置与旧代码一致:`--body` 紧跟判定 flag,老调用 argv 逐字节不变
473
+ reviewCmd.push(...body.argv);
474
+ const res = await executor.run(reviewCmd, { ...runOpts, ...stdinPatch(body) });
458
475
  if (!res.ok)
459
476
  throw new Error(res.error);
460
477
  return { message: 'Review 提交成功', detail: res.rawOutput };
@@ -509,11 +526,15 @@ export function createPrTool(executor) {
509
526
  return { message: `PR #${args.pr_number} 分支已从基准分支同步更新`, output: res.rawOutput };
510
527
  }
511
528
  case 'comment': {
512
- if (!args.pr_number || !args.body)
513
- throw new Error('comment 必须提供 pr_number 和 body');
514
- const commentCmd = ['pr', 'comment', String(args.pr_number), ...repoArgs, '--body', args.body];
529
+ if (!args.pr_number)
530
+ throw new Error('comment 操作必须提供 pr_number');
531
+ const body = resolveBodySource({ inline: args.body, file: args.body_file }, { cwd });
532
+ // 判空看解析后的权威正文字符数:文件来源时 `args.body` 恒为空,不能拿它做必填判定
533
+ if (body.chars === 0)
534
+ throw new Error('comment 必须提供 body 或 body_file(评论正文)');
535
+ const commentCmd = ['pr', 'comment', String(args.pr_number), ...repoArgs, ...body.argv];
515
536
  await appendAttachFlags(commentCmd, args.attach_images);
516
- const res = await executor.run(commentCmd, runOpts);
537
+ const res = await executor.run(commentCmd, { ...runOpts, ...stdinPatch(body) });
517
538
  if (!res.ok)
518
539
  throw new Error(res.error);
519
540
  return { message: 'PR 评论发布成功', output: res.rawOutput };
@@ -521,13 +542,13 @@ export function createPrTool(executor) {
521
542
  case 'edit': {
522
543
  if (!args.pr_number)
523
544
  throw new Error('edit 操作必须提供 pr_number');
545
+ const body = resolveBodySource({ inline: args.body, file: args.body_file }, { cwd });
524
546
  const editCmd = ['pr', 'edit', String(args.pr_number), ...repoArgs];
525
547
  if (args.title)
526
548
  editCmd.push('--title', args.title);
527
- if (args.body)
528
- editCmd.push('--body', args.body);
549
+ editCmd.push(...body.argv);
529
550
  await appendAttachFlags(editCmd, args.attach_images);
530
- const res = await executor.run(editCmd, runOpts);
551
+ const res = await executor.run(editCmd, { ...runOpts, ...stdinPatch(body) });
531
552
  if (!res.ok)
532
553
  throw new Error(res.error);
533
554
  return { message: `PR #${args.pr_number} 更新成功`, output: res.rawOutput };
@@ -26,6 +26,10 @@ export declare function createReleaseTool(executor: GhExecutor): {
26
26
  type: string;
27
27
  description: string;
28
28
  };
29
+ notes_file: {
30
+ type: string;
31
+ description: string;
32
+ };
29
33
  generate_notes: {
30
34
  type: string;
31
35
  description: string;
@@ -1,6 +1,7 @@
1
1
  import { wrapWithGate } from '../destructive.js';
2
2
  import { renderReleaseMarkdown } from '../renderers/markdown.js';
3
3
  import { assertPositionArg } from '../executor/args-guard.js';
4
+ import { resolveBodySource } from './body-input.js';
4
5
  function normalizeOutput(data) {
5
6
  if (data && typeof data === 'object' && !Array.isArray(data)) {
6
7
  return data;
@@ -36,7 +37,11 @@ export function createReleaseTool(executor) {
36
37
  },
37
38
  notes: {
38
39
  type: 'string',
39
- description: 'Release 说明正文(create 动作使用)。留空且 generate_notes 为 true 时将自动由 GitHub 根据提交记录生成。',
40
+ description: 'Release 说明正文(create 动作使用)。留空且 generate_notes 为 true 时将自动由 GitHub 根据提交记录生成。与 notes_file 二选一。',
41
+ },
42
+ notes_file: {
43
+ type: 'string',
44
+ description: '发版说明来源:本地 Markdown 文件路径(相对路径按当前工作区解析)。与 notes 二选一。',
40
45
  },
41
46
  generate_notes: {
42
47
  type: 'boolean',
@@ -108,6 +113,8 @@ export function createReleaseTool(executor) {
108
113
  }
109
114
  const repoArgs = ['-R', repoTarget];
110
115
  const runOpts = { cwd, sessionId };
116
+ // 说明来自本地文件时经 stdin 注入(`--notes-file -`):绕开 Windows 约 32K 的命令行上限
117
+ const stdinPatch = (body) => (body.stdin === undefined ? {} : { stdin: body.stdin });
111
118
  switch (args.action) {
112
119
  case 'list': {
113
120
  // 显式 limit 优先;缺省回退 executor.defaultListLimit(上限 50 防止全量 Release 撑爆输出)
@@ -139,8 +146,12 @@ export function createReleaseTool(executor) {
139
146
  if (args.title)
140
147
  createCmd.push('--title', args.title);
141
148
  // notes 与 generate_notes 可共存:gh 会在自动生成的说明前 prepend 手写正文
142
- if (args.notes)
143
- createCmd.push('--notes', args.notes);
149
+ const notes = resolveBodySource({ inline: args.notes, file: args.notes_file },
150
+ // maxChars: 0 —— Release 说明的 GitHub 上限比 Issue/评论正文(65,536)宽得多,
151
+ // 套用正文口径会把合法的长发版说明先拦下来;这里只保留 4MB 字节护栏,
152
+ // 真正超限由 GitHub 侧报错(错误信息带字段名,可定位)。
153
+ { cwd, flag: '--notes', fileFlag: '--notes-file', inlineField: 'notes', fileField: 'notes_file', maxChars: 0 });
154
+ createCmd.push(...notes.argv);
144
155
  if (args.generate_notes !== false)
145
156
  createCmd.push('--generate-notes');
146
157
  if (args.draft === true)
@@ -149,7 +160,7 @@ export function createReleaseTool(executor) {
149
160
  createCmd.push('--prerelease');
150
161
  if (args.target)
151
162
  createCmd.push('--target', args.target);
152
- const res = await executor.run(createCmd, runOpts);
163
+ const res = await executor.run(createCmd, { ...runOpts, ...stdinPatch(notes) });
153
164
  if (!res.ok)
154
165
  throw new Error(res.error);
155
166
  return { message: `Release ${args.tag} 创建成功`, url: res.rawOutput };
@@ -53,6 +53,10 @@ export declare function createTaskTool(executor: GhExecutor): {
53
53
  type: string;
54
54
  description: string;
55
55
  };
56
+ body_file: {
57
+ type: string;
58
+ description: string;
59
+ };
56
60
  comments: {
57
61
  type: string;
58
62
  items: {
package/lib/tools/task.js CHANGED
@@ -16,6 +16,7 @@ import { wrapWithGate } from '../destructive.js';
16
16
  import { readReviewRules } from '../review-rules.js';
17
17
  import { extractFailureAnchors } from '../executor/failure-anchors.js';
18
18
  import { renderTaskMarkdown } from '../renderers/markdown.js';
19
+ import { resolveBodySource } from './body-input.js';
19
20
  import { classifyCommits, extractFileAnchors, finalizeEnvelope, renderReleaseNotes, selectFilesByBudget, suggestVersion, } from '../task-primitives.js';
20
21
  /**
21
22
  * 把 `pr checks` 的 bucket/state 归并成四态计数。
@@ -92,7 +93,11 @@ export function createTaskTool(executor) {
92
93
  },
93
94
  body: {
94
95
  type: 'string',
95
- description: 'review_pr 写回时的整体说明(缺省会被补 [P2] 严重度前缀)。',
96
+ description: 'review_pr 写回时的整体说明(缺省会被补 [P2] 严重度前缀)。与 body_file 二选一。',
97
+ },
98
+ body_file: {
99
+ type: 'string',
100
+ description: '写回说明来源:本地 Markdown 文件路径(相对路径按当前工作区解析)。与 body 二选一;长正文用本参数,无需把全文读进上下文。',
96
101
  },
97
102
  comments: {
98
103
  type: 'array',
@@ -269,9 +274,11 @@ export function createTaskTool(executor) {
269
274
  const envelope = built.envelope;
270
275
  // 写回:只有显式 write + confirm(闸门已在 wrapWithGate 里挡过一次)才执行
271
276
  if (args.write === true) {
277
+ // 行级评注走 REST,没有 `--body-file` 可用——正文只能由插件读文件后塞进 JSON 请求体
278
+ const reviewBody = resolveBodySource({ inline: args.body, file: args.body_file }, { cwd });
272
279
  const res = await executor.createPrReview(cwd, repoTarget, prNumber, {
273
280
  event: args.review_decision,
274
- body: args.body,
281
+ body: reviewBody.text,
275
282
  comments: Array.isArray(args.comments) ? args.comments : [],
276
283
  commit_id: d.headRefOid,
277
284
  });
@@ -1,3 +1,5 @@
1
+ import { FileDiffModal } from './file-diff-modal.js';
2
+ import { splitUnifiedPatch, findPatchSection, normalizeRenamePath } from './diff-utils.js';
1
3
  /**
2
4
  * 提交详情模态弹窗组件(与设置弹窗同构)
3
5
  */
@@ -8,14 +10,76 @@ export function CommitDetailModal(props) {
8
10
  var h = React.createElement;
9
11
  var [fetchedDetail, setFetchedDetail] = React.useState(null);
10
12
  var [fetchingDetail, setFetchingDetail] = React.useState(false);
13
+ /**
14
+ * 点某个文件后的 diff 预览状态。
15
+ *
16
+ * 为什么补丁要懒加载而不是随弹窗一起取:多数时候用户只想看提交说明,
17
+ * 大提交的整包 patch 可以到几百 KB;只在真的点开某个文件时才拉一次,且整个
18
+ * 弹窗生命周期内复用同一份 patch(点第二个文件零延迟)。
19
+ */
20
+ var [patchState, setPatchState] = React.useState(null);
21
+ var [patchLoading, setPatchLoading] = React.useState(false);
22
+ var [patchError, setPatchError] = React.useState(null);
23
+ var [selectedFile, setSelectedFile] = React.useState(null);
24
+ var loadPatch = function () {
25
+ if (patchState || patchLoading)
26
+ return;
27
+ setPatchLoading(true);
28
+ setPatchError(null);
29
+ var url = "/api/github/commit-diff?sha=" + encodeURIComponent(c.sha) + (props.cwd ? "&cwd=" + encodeURIComponent(props.cwd) : "");
30
+ fetch(url)
31
+ .then(function (r) {
32
+ if (r.status === 404) {
33
+ setPatchLoading(false);
34
+ setPatchError("后端未提供提交补丁接口 (404)。请重启 DSH 桌面主程序以载入最新对比引擎。");
35
+ return null;
36
+ }
37
+ return r.json().catch(function () { return null; });
38
+ })
39
+ .then(function (j) {
40
+ if (!j)
41
+ return;
42
+ setPatchLoading(false);
43
+ if (j.ok && j.data && typeof j.data.patch === "string") {
44
+ setPatchState({ patch: j.data.patch, truncated: Boolean(j.data.truncated) });
45
+ }
46
+ else {
47
+ setPatchError((j && j.error) || "获取提交补丁失败");
48
+ }
49
+ })
50
+ .catch(function (err) {
51
+ setPatchLoading(false);
52
+ setPatchError("网络请求异常:" + ((err && err.message) || "未知错误"));
53
+ });
54
+ };
55
+ var openFileDiff = function (f) {
56
+ setSelectedFile(f);
57
+ loadPatch();
58
+ };
11
59
  React.useEffect(function () {
12
60
  var onKeyDown = function (e) {
13
- if (e.key === "Escape" || e.keyCode === 27)
61
+ if (e.key === "Escape" || e.keyCode === 27) {
62
+ // 逐层关闭:先收内层 diff 预览,再按一次才关整个提交详情
63
+ if (selectedFile) {
64
+ setSelectedFile(null);
65
+ return;
66
+ }
14
67
  props.onClose();
68
+ }
15
69
  };
16
70
  window.addEventListener("keydown", onKeyDown);
17
71
  return function () { window.removeEventListener("keydown", onKeyDown); };
18
- }, [props.onClose]);
72
+ }, [props.onClose, selectedFile]);
73
+ /**
74
+ * 换提交时清空上一份补丁与选中文件。
75
+ * 当前调用方在关闭时卸载组件(状态自然复位),但组件一旦被复用(不 key、不卸载),
76
+ * 残留的 patch 会把 A 提交的差异套到 B 提交的文件路径上——这类错配极难排查,先兜住。
77
+ */
78
+ React.useEffect(function () {
79
+ setPatchState(null);
80
+ setPatchError(null);
81
+ setSelectedFile(null);
82
+ }, [c.sha]);
19
83
  React.useEffect(function () {
20
84
  setFetchedDetail(null);
21
85
  if (c.body || c.stat)
@@ -39,6 +103,13 @@ export function CommitDetailModal(props) {
39
103
  var parsedStat = React.useMemo(() => {
40
104
  return effectiveStat && typeof parseGitStatText === "function" ? parseGitStatText(effectiveStat) : null;
41
105
  }, [effectiveStat]);
106
+ /**
107
+ * 切段结果必须缓存:大提交的整包 patch 可达 400k 字符,而本组件在弹窗生命周期里
108
+ * 会因 selectedFile / patchError 等状态多次重渲染——每次重渲染都重新切一遍是纯浪费。
109
+ */
110
+ var patchSections = React.useMemo(function () {
111
+ return patchState ? splitUnifiedPatch(patchState.patch) : [];
112
+ }, [patchState]);
42
113
  return h("div", {
43
114
  className: "dsh-github-modal-overlay",
44
115
  onClick: props.onClose,
@@ -142,6 +213,18 @@ export function CommitDetailModal(props) {
142
213
  return h("div", {
143
214
  key: idx,
144
215
  className: "dsh-github-stat-row",
216
+ // 可点击 + 可键盘操作:文件行是入口而不是纯展示,缺 aria/键盘会让
217
+ // 只用 Tab 的人根本发现不了「能点开看 diff」
218
+ role: "button",
219
+ tabIndex: 0,
220
+ "aria-label": "查看 " + f.path + " 的差异",
221
+ onClick: function () { openFileDiff(f); },
222
+ onKeyDown: function (e) {
223
+ if (e.key === "Enter" || e.key === " " || e.keyCode === 13 || e.keyCode === 32) {
224
+ e.preventDefault();
225
+ openFileDiff(f);
226
+ }
227
+ },
145
228
  style: {
146
229
  display: "flex",
147
230
  alignItems: "center",
@@ -151,8 +234,10 @@ export function CommitDetailModal(props) {
151
234
  lineHeight: "1.4",
152
235
  borderBottom: idx < parsedStat.files.length - 1 ? "1px solid var(--dsw-alias-border-subtle, rgba(0,0,0,0.06))" : "none",
153
236
  gap: "8px",
237
+ cursor: "pointer",
238
+ userSelect: "none",
154
239
  },
155
- title: f.path + (f.isBinary ? " (二进制文件)" : ` (+${f.additions}, -${f.deletions})`),
240
+ title: f.path + (f.isBinary ? " (二进制文件)" : ` (+${f.additions}, -${f.deletions})`) + "\n点击查看该文件的代码改动",
156
241
  }, h("div", {
157
242
  style: {
158
243
  fontFamily: "ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace",
@@ -193,6 +278,7 @@ export function CommitDetailModal(props) {
193
278
  style: { display: "flex", gap: "1px", marginLeft: "2px", alignItems: "center" },
194
279
  }, ...diffBlocks)
195
280
  : null,
281
+ h("span", { key: "chev", style: { opacity: 0.4, marginLeft: "2px", fontSize: "12px" }, "aria-hidden": "true" }, "›"),
196
282
  ]));
197
283
  }))
198
284
  : h("pre", {
@@ -215,5 +301,23 @@ export function CommitDetailModal(props) {
215
301
  ? (fetchingDetail
216
302
  ? h("div", { style: { fontSize: "12px", opacity: 0.7, padding: "8px 0" } }, "⏳ 正在获取详细变更说明…")
217
303
  : h("div", { style: { fontSize: "11px", opacity: 0.6, fontStyle: "italic", padding: "6px 0" } }, "(该提交未包含多行正文;重启 DSH 桌面端可同步最新格式并直接呈现变更统计)"))
218
- : null)));
304
+ : null)),
305
+ // 内层 diff 预览:作为 overlay 的兄弟节点而非 dialog 的子节点,
306
+ // 这样它是独立的一层遮罩(zIndex 更高),点遮罩只关它自己
307
+ selectedFile
308
+ ? h(FileDiffModal, {
309
+ file: selectedFile,
310
+ cwd: props.cwd,
311
+ // 传字符串而不是 null:null 会被组件当成「未提供正文 → 自己去拉未提交 diff」
312
+ diffText: (findPatchSection(patchSections, normalizeRenamePath(selectedFile.path)) || { text: "" }).text,
313
+ subtitle: String(c.fullSha || c.sha || "").slice(0, 7),
314
+ loading: patchLoading,
315
+ error: patchError,
316
+ note: patchState && patchState.truncated
317
+ ? "该提交差异过大,预览只覆盖补丁被截断前的文件;其余文件请用命令行查看。"
318
+ : null,
319
+ zIndex: 100001,
320
+ onClose: function () { setSelectedFile(null); },
321
+ })
322
+ : null);
219
323
  }