dsh-github-flow 0.6.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.
Files changed (95) hide show
  1. package/README.en.md +102 -15
  2. package/README.md +105 -15
  3. package/docs/UPGRADE-NOTES.md +12 -11
  4. package/docs/architecture.md +67 -14
  5. package/lib/api/route-helpers.d.ts +19 -0
  6. package/lib/api/route-helpers.js +54 -0
  7. package/lib/api/routes-actions.js +126 -2
  8. package/lib/api/routes-lifecycle.js +8 -1
  9. package/lib/api/routes-overview.d.ts +26 -0
  10. package/lib/api/routes-overview.js +276 -34
  11. package/lib/api/routes-proxy.js +8 -1
  12. package/lib/api/routes.d.ts +11 -1
  13. package/lib/api/routes.js +15 -2
  14. package/lib/cache.d.ts +0 -21
  15. package/lib/cache.js +0 -37
  16. package/lib/client.d.ts +2 -1
  17. package/lib/client.js +2108 -658
  18. package/lib/destructive.d.ts +59 -0
  19. package/lib/destructive.js +285 -0
  20. package/lib/executor/args-guard.d.ts +17 -0
  21. package/lib/executor/args-guard.js +23 -0
  22. package/lib/executor/failure-anchors.d.ts +52 -0
  23. package/lib/executor/failure-anchors.js +116 -0
  24. package/lib/executor/pr-review.d.ts +95 -0
  25. package/lib/executor/pr-review.js +175 -0
  26. package/lib/executor/run-log.d.ts +36 -0
  27. package/lib/executor/run-log.js +78 -0
  28. package/lib/executor.d.ts +204 -3
  29. package/lib/executor.js +670 -28
  30. package/lib/github-service.d.ts +1 -1
  31. package/lib/main.d.ts +92 -18
  32. package/lib/main.js +186 -95
  33. package/lib/renderers/common.d.ts +0 -3
  34. package/lib/renderers/common.js +8 -44
  35. package/lib/renderers/markdown.d.ts +1 -0
  36. package/lib/renderers/markdown.js +1 -0
  37. package/lib/renderers/pr.js +26 -1
  38. package/lib/renderers/task.d.ts +9 -0
  39. package/lib/renderers/task.js +120 -0
  40. package/lib/review-rules.d.ts +60 -0
  41. package/lib/review-rules.js +204 -0
  42. package/lib/shared/state-badges.d.ts +42 -0
  43. package/lib/shared/state-badges.js +102 -0
  44. package/lib/task-primitives.d.ts +109 -0
  45. package/lib/task-primitives.js +194 -0
  46. package/lib/tool-tiers.d.ts +57 -0
  47. package/lib/tool-tiers.js +168 -0
  48. package/lib/tools/agent-task.d.ts +4 -0
  49. package/lib/tools/agent-task.js +22 -9
  50. package/lib/tools/api.d.ts +4 -0
  51. package/lib/tools/api.js +48 -12
  52. package/lib/tools/body-input.d.ts +58 -0
  53. package/lib/tools/body-input.js +123 -0
  54. package/lib/tools/cache.js +42 -40
  55. package/lib/tools/codespace.js +4 -12
  56. package/lib/tools/discussion.d.ts +4 -0
  57. package/lib/tools/discussion.js +33 -17
  58. package/lib/tools/gist.d.ts +1 -39
  59. package/lib/tools/gist.js +4 -14
  60. package/lib/tools/help.js +65 -13
  61. package/lib/tools/issue.d.ts +4 -0
  62. package/lib/tools/issue.js +37 -38
  63. package/lib/tools/label.js +4 -15
  64. package/lib/tools/pr.d.ts +51 -0
  65. package/lib/tools/pr.js +204 -46
  66. package/lib/tools/project.js +4 -13
  67. package/lib/tools/release.d.ts +4 -0
  68. package/lib/tools/release.js +26 -26
  69. package/lib/tools/repo.js +4 -4
  70. package/lib/tools/ruleset.js +9 -6
  71. package/lib/tools/run.js +76 -24
  72. package/lib/tools/secret.js +11 -17
  73. package/lib/tools/skill.js +18 -22
  74. package/lib/tools/status.js +4 -4
  75. package/lib/tools/task.d.ts +223 -0
  76. package/lib/tools/task.js +464 -0
  77. package/lib/types.d.ts +183 -1
  78. package/lib/ui/commit-modal.js +108 -4
  79. package/lib/ui/diff-utils.d.ts +51 -0
  80. package/lib/ui/diff-utils.js +155 -0
  81. package/lib/ui/file-diff-modal.d.ts +33 -0
  82. package/lib/ui/file-diff-modal.js +250 -0
  83. package/lib/ui/help-modal.d.ts +1 -1
  84. package/lib/ui/help-modal.js +24 -12
  85. package/lib/ui/issue-modal.js +7 -3
  86. package/lib/ui/pr-modal.js +50 -11
  87. package/lib/ui/run-modal.d.ts +26 -0
  88. package/lib/ui/run-modal.js +486 -0
  89. package/lib/ui/styles.d.ts +1 -1
  90. package/lib/ui/styles.js +376 -44
  91. package/lib/ui/sync-settings-modal.js +1 -1
  92. package/lib/ui/workspace-panel.js +78 -23
  93. package/lib/version.d.ts +1 -1
  94. package/lib/version.js +1 -1
  95. package/package.json +9 -2
package/lib/executor.js CHANGED
@@ -12,8 +12,10 @@ import { extractImageUrls } from './tools/image-utils.js';
12
12
  // 子模块纯函数解耦导入并对外部 Re-export 保证 100% 向后兼容
13
13
  import { parseGhVersion, compareGhVersions } from './executor/version-utils.js';
14
14
  import { spawnWithStdin, safeSlice, cutByUtf8Bytes, truncateOutput } from './executor/process-utils.js';
15
+ import { buildPrReviewPayload, foldCommentsIntoBody, isLineCommentRejected } from './executor/pr-review.js';
15
16
  import { parseGitStatusOutput, parseGitLogLines, parseGitBranchLines, parseUncommittedDiff, parseGitStatText, parseRemoteUrls, parseGitConfig, resolveRemoteTarget, parseGithubUrl, parseRemoteHost, classifyRepoLayout, pickGithubRemote, splitUnifiedDiff, } from './executor/git-parser.js';
16
17
  import { getPlatformInstallGuide, getGhUpgradeHint } from './executor/platform-guide.js';
18
+ import { buildPlainFailureExcerpt, interpretRunLogError } from './executor/run-log.js';
17
19
  import { classifyGhError, describeDeterministicError, isRetryableError, permissionRemedy, isPermissionError } from './executor/error-classifier.js';
18
20
  export { parseGhVersion, compareGhVersions, spawnWithStdin, safeSlice, cutByUtf8Bytes, truncateOutput, parseGitStatusOutput, parseGitLogLines, parseGitBranchLines, parseUncommittedDiff, parseGitStatText, parseRemoteUrls, parseGitConfig, resolveRemoteTarget, parseGithubUrl, parseRemoteHost, classifyRepoLayout, pickGithubRemote, splitUnifiedDiff, getPlatformInstallGuide, getGhUpgradeHint, classifyGhError, describeDeterministicError, isRetryableError, permissionRemedy, isPermissionError, };
19
21
  const execFileAsync = promisify(execFile);
@@ -49,6 +51,35 @@ const ISSUE_DETAIL_FULL_FIELDS = 'number,title,state,stateReason,author,createdA
49
51
  * 正文与评论时间线——缺失的里程碑/关联 PR 只是少几个装饰位,不致命。
50
52
  */
51
53
  const ISSUE_DETAIL_BASE_FIELDS = 'number,title,state,author,createdAt,updatedAt,url,body,labels,assignees,milestone,comments';
54
+ /**
55
+ * `gh run view --json` 的完整字段集(gh 2.96.0 实测合法全集)。
56
+ *
57
+ * `jobs` 的值是一个树:job{ databaseId,name,status,conclusion,startedAt,
58
+ * completedAt,url,steps[] },step{ number,name,status,conclusion,startedAt,
59
+ * completedAt }。**step 没有 url**,所以 UI 只能给到 job 级外链。
60
+ * `attempt` 供「第 N 次重跑」显示;`displayTitle` 在 workflow 名称之外给出
61
+ * 这次运行的业务描述(PR 标题/提交信息)。
62
+ */
63
+ const RUN_DETAIL_FULL_FIELDS = 'attempt,conclusion,createdAt,databaseId,displayTitle,event,headBranch,headSha,jobs,name,number,startedAt,status,updatedAt,url,workflowDatabaseId,workflowName';
64
+ /**
65
+ * Run 详情的降级字段集(老 gh 遇未知字段会整体失败,与 issue 详情同一处置)。
66
+ *
67
+ * 只保留 jobs 这一棵不可替代的树 + 少量基础元数据:缺了 attempt/displayTitle
68
+ * 只是少两个装饰位,缺了 jobs 这个功能就不存在了。
69
+ */
70
+ const RUN_DETAIL_BASE_FIELDS = 'databaseId,name,status,conclusion,jobs,url,createdAt';
71
+ /** 单次按需拉日志的 job 上限:矩阵构建下几十个失败 job 不再是「一次点击」能承受的量 */
72
+ const RUN_LOG_MAX_JOBS = 3;
73
+ /** 日志摘要的字符预算:不进程模型上下文,只进浏览器 DOM,但仍要防几万字拖卡渲染 */
74
+ const RUN_LOG_BUDGET_CHARS = 120_000;
75
+ /**
76
+ * 单次日志请求的总时间预算(毫秒)。
77
+ *
78
+ * 为什么不是「每个 job 60s,有 3 个就 180s」:这条链路挂在 HTTP 路由上,宿主
79
+ * WebServer 是整个 GUI 共享的。一次点击把连接占住三分钟,等于把面板卡死。
80
+ * 超预算的 job 会进 errors[](可单独重试),用户能在 90s 内拿到已拉到的部分。
81
+ */
82
+ const RUN_LOG_TOTAL_BUDGET_MS = 90_000;
52
83
  /**
53
84
  * 把 `gh repo view --json` 的原始结果映射为 RepoMetadata。
54
85
  *
@@ -96,6 +127,14 @@ export class GhExecutor {
96
127
  maxOutputChars;
97
128
  /** UTF-8 字节口径的硬上限;未配置时按 maxOutputChars × 4 推导(覆盖 CJK 3 字节与 emoji 4 字节) */
98
129
  maxOutputBytes;
130
+ /**
131
+ * 累计 gh 子进程调用次数(只增不清零,`run()` 每次 +1)。
132
+ *
133
+ * 为什么需要:任务原语(github_task)要如实汇报 `cost.ghCalls`,
134
+ * 而「原语是否真的比手动串步骤省钱」是这个能力存在的唯一判据。
135
+ * 消费方用「调用前快照 → 调用后求差」的方式取区间计数。
136
+ */
137
+ ghCalls = 0;
99
138
  /**
100
139
  * 列表类查询的默认条数上限。
101
140
  * 为什么挂在 executor 而不是给每个工具工厂加 config 参数:executor 是全部
@@ -180,11 +219,63 @@ export class GhExecutor {
180
219
  return recent;
181
220
  return explicitCwd || process.cwd();
182
221
  }
222
+ /**
223
+ * 18 个工具的唯一工作区入口:把 execContext 归一成 `{ cwd, sessionId }`。
224
+ *
225
+ * 为什么必须走这里而不是各工具写 `execContext?.cwd || process.cwd()`:
226
+ * 三级解析里第 1 级是「显式 cwd 且磁盘存在即原样返回」,而 `process.cwd()`
227
+ * 恒满足该条件——于是第 2 级「按 sessionId 反查真实工作区」**永远不会触发**。
228
+ * 工具层把 `process.cwd()` 当兜底传下去,等于把整个会话亲和机制架空。
229
+ *
230
+ * ⚠️ 刻意**不复用** `resolveWorkspaceCwd` 的第 3 级(`findMostRecentWorkspacePath`):
231
+ * 那一级是给「完全无上下文的 CLI 场景」用的,会把目录定到**最近活跃的任意仓库**。
232
+ * 工具调用的默认目录必须可预测——落到一个与本会话无关的仓库意味着读错库、
233
+ * 更糟的是写到错库(merge / release create / label delete 都会打错地方)。
234
+ * 实测:抹掉 DSH_SESSION_ID 后 `resolveWorkspaceCwd(undefined, undefined)` 返回的是
235
+ * 一个与本会话无关的仓库路径,而 `process.cwd()` 是当前进程目录。
236
+ *
237
+ * 解析顺序:显式 cwd(原样透传,含不存在的情况,与旧行为一致)→ session 反查 →
238
+ * process.cwd()。三级都拿不到时才回到进程目录,与 s5 之前的兜底完全一致。
239
+ *
240
+ * ⚠️ `agent.id` 必须参与反查:DSH 真实的 ToolRunContext(宿主 tools 服务的
241
+ * `ToolExecutionInput`)只有 `agent: { id: SessionId }`,**既没有 cwd 也没有 sessionId**。
242
+ * 早先只认 `sessionId` / `session.id` / 环境变量,于是这一级在桌面端永远不触发,
243
+ * 所有工具退化到宿主进程的 process.cwd()(实测是 profile 目录)——表现为 `repo`
244
+ * 留空时无法自动识别仓库、`body_file` 这类相对路径参数被解析到错误目录。
245
+ * 这里改成「候选逐个试到命中为止」而非短路:任一来源能反查到工作区就返回。
246
+ */
247
+ resolveExecScope(execContext) {
248
+ const candidates = [
249
+ execContext?.sessionId,
250
+ execContext?.session?.id,
251
+ execContext?.agent?.id,
252
+ process.env.DSH_SESSION_ID,
253
+ ].filter((id) => typeof id === 'string' && id.length > 0);
254
+ // 1) 显式 cwd 原样透传:这是唯一事实源,不做存在性替换,也不许被会话反查覆盖
255
+ if (execContext?.cwd)
256
+ return { cwd: execContext.cwd, sessionId: candidates[0] };
257
+ // 2) 按会话反查真实工作区:候选之间是或关系,任一击即返回(查出空就试下一个)
258
+ for (const sessionId of candidates) {
259
+ const fromSession = findWorkspacePathBySession(sessionId);
260
+ if (fromSession)
261
+ return { cwd: fromSession, sessionId };
262
+ }
263
+ // 3) 兜底到进程目录。**不**调用 findMostRecentWorkspacePath()
264
+ return { cwd: process.cwd(), sessionId: candidates[0] };
265
+ }
183
266
  /**
184
267
  * 安全执行 gh 命令
185
268
  * 采用 execFile 参数数组隔离,彻底杜绝 Shell 字符串拼接注入漏洞
186
269
  */
187
270
  async run(args, options = {}) {
271
+ /*
272
+ * gh 子进程调用计数(只增不清零)。
273
+ *
274
+ * 为什么放在 executor 而不是各工具里手数:任务原语(github_task)必须能如实
275
+ * 汇报 `cost.ghCalls`,而「原语是否真的省钱」是它存在的唯一判据。
276
+ * 手写计数必然与实际调用漂移——真实调用点只有这里一个。
277
+ */
278
+ this.ghCalls++;
188
279
  const cwd = this.resolveWorkspaceCwd(options.cwd, options.sessionId);
189
280
  const timeout = options.timeoutMs || this.defaultTimeoutMs;
190
281
  // 二进制模式(如 gh api 下载图片字节流):stdout 保持 Buffer,绕过 UTF-8
@@ -248,7 +339,15 @@ export class GhExecutor {
248
339
  if (!rawOutput && errText) {
249
340
  rawOutput = errText.trim();
250
341
  }
251
- const outcome = truncateOutput(rawOutput, options.maxOutputChars ?? this.maxOutputChars, options.maxOutputBytes ?? this.maxOutputBytes);
342
+ const maxChars = options.maxOutputChars ?? this.maxOutputChars;
343
+ // 字节上限联动推导:若调用方显式放宽了字符上限(如放宽到 1MB),未指定 maxOutputBytes 时
344
+ // 必须按 4 倍自动放宽,绝不能死板回落到全局实例的 96KB(否则 >96KB 的输出会被静默判定截断)。
345
+ // 取 Math.max 是为了不压低用户在 Config 里配置的更大 maxOutputBytes。
346
+ const maxBytes = options.maxOutputBytes ??
347
+ (options.maxOutputChars
348
+ ? Math.max(options.maxOutputChars * 4, this.maxOutputBytes)
349
+ : this.maxOutputBytes);
350
+ const outcome = truncateOutput(rawOutput, maxChars, maxBytes);
252
351
  rawOutput = outcome.text;
253
352
  const truncated = outcome.truncated;
254
353
  // 如果未被截断且命令带有 --json 或包含 api,尝试解析为 JSON
@@ -425,18 +524,20 @@ export class GhExecutor {
425
524
  * 零网络:status 基于 remote-tracking ref(上次 fetch 的快照),毫秒级本地读取,
426
525
  * 符合 global-overview 端点「工作区探测不发网络」的性能红线。
427
526
  */
428
- async getLocalGitStatus(cwd) {
527
+ async getLocalGitStatus(cwd, timeoutMs = 3_000) {
429
528
  try {
430
- // 纯本地毫秒级;3s 超时兜底异常文件系统
529
+ // 默认 3s:概览面板只是"读不到就不渲染这一行"的兜底语义。
530
+ // 但安全闸门(ensureCleanWorkspace)必须显式传更长超时——大型仓库 / 网络盘上
531
+ // `git status` 冷启动超 3s 很常见,用 3s 会把"读不到"误判成"有风险"。
431
532
  const { stdout } = await execFileAsync('git', ['status', '-sb', '--porcelain'], {
432
533
  cwd: cwd || undefined,
433
- timeout: 3_000,
534
+ timeout: timeoutMs,
434
535
  windowsHide: true,
435
536
  });
436
537
  return parseGitStatusOutput(String(stdout), 2000);
437
538
  }
438
539
  catch {
439
- // 非 Git 目录 / git 未安装 / 超时:静默降级为 null(调用方不渲染该行)
540
+ // 非 Git 目录 / git 未安装 / 超时:静默降级为 null(调用方自行决定语义)
440
541
  return null;
441
542
  }
442
543
  }
@@ -486,7 +587,9 @@ export class GhExecutor {
486
587
  }
487
588
  try {
488
589
  // 1. 先用 porcelain status 获取该文件的状态码
489
- let fileStatus = 'M';
590
+ // 默认值必须为空:旧实现默认 'M',导致工作区干净的文件被报成「已修改」
591
+ // (UI 表现为"文件被改了,但 diff 是空的")。状态只从 porcelain 结果推导。
592
+ let fileStatus = '';
490
593
  try {
491
594
  const { stdout: statusOut } = await execFileAsync('git', ['status', '--porcelain', '--', cleanRel], {
492
595
  cwd: targetCwd || undefined,
@@ -582,7 +685,7 @@ export class GhExecutor {
582
685
  return {
583
686
  ok: true,
584
687
  filePath: cleanRel,
585
- status: fileStatus,
688
+ status: fileStatus || 'M',
586
689
  isBinary: true,
587
690
  diff: 'Binary files differ',
588
691
  additions: 0,
@@ -599,7 +702,8 @@ export class GhExecutor {
599
702
  return {
600
703
  ok: true,
601
704
  filePath: cleanRel,
602
- status: fileStatus,
705
+ // porcelain 没给状态且没有 diff → 该文件其实是干净的,显式标 'clean' 而不是伪造成 'M'
706
+ status: fileStatus || (finalDiff ? 'M' : 'clean'),
603
707
  diff: finalDiff || '没有检测到文本差异',
604
708
  additions: diffStats.additions,
605
709
  deletions: diffStats.deletions,
@@ -718,6 +822,70 @@ export class GhExecutor {
718
822
  return null;
719
823
  }
720
824
  }
825
+ /**
826
+ * 某次提交的完整补丁(提交详情弹窗里点单个文件时,前端按 `diff --git` 切分后取对应段)。
827
+ *
828
+ * 为什么整包取一次,而不是按文件逐个 `git show <sha> -- <path>`:
829
+ * 用户点哪个文件是未知的,逐个文件来一次就是 N 次进程启动(Windows 上每次几十毫秒),
830
+ * 同一提交内多个文件还要重复解析。一次拿全量交给前端切分,把延迟从「每次点击」
831
+ * 摊到「打开弹窗一次」。
832
+ *
833
+ * 白名单纪律延续 getGitCommitDetail:sha 先过 `/^[0-9a-f]{4,40}$/i` 才拼接,
834
+ * 值域锁死为纯十六进制,注入面为零;命令形态整体常量硬编码。各选项为什么存在:
835
+ * - `--format=`:清空提交头。默认输出前半截是 commit/Author/Date/message,
836
+ * 前端按 `diff --git` 切片时会把这段脏头当成第一个文件的内容;
837
+ * - `--no-ext-diff`:用户一旦配置 `diff.external`,git 会去执行任意外部程序。
838
+ * 这是本地服务,不能因为一条读请求就把用户的外部工具跑起来;
839
+ * - `--no-color`:用户若设了 `color.ui=always`,patch 里会混进 ANSI 转义序列,
840
+ * 前端按行首 `+`/`-` 判定增删行的逻辑会直接判错;
841
+ * - `core.quotepath=false`:quotepath 为 true(git 内建默认)时非 ASCII 路径被转义成
842
+ * `\344\270\255` 并包上双引号(实测),前端按 `a/` 前缀切出的文件名与真实路径对不上;
843
+ * - `--src-prefix=a/ --dst-prefix=b/`:用户全局开 `diff.noprefix=true` 时路径没有
844
+ * `a/`/`b/` 前缀,切分与还原文件名的逻辑全部错位,这里强制钉死前缀;
845
+ * - 末尾 `--`:关死 sha 之后的参数位,避免可被解释成 pathspec 的输入混进命令。
846
+ *
847
+ * 体积护栏:大提交的 patch 可达数十 MB,整包透传给 WebView 会卡死渲染。超过
848
+ * 400_000 字符时在**最后一个 `\ndiff --git ` 边界**处截断——绝不切在 hunk 中间,
849
+ * 否则前端拿到半截 hunk 会渲染出错行;只有找不到边界(极端单文件巨型 diff)
850
+ * 才硬切到上限,此时 truncated 标记让前端给出「已截断」提示。
851
+ *
852
+ * 空 patch 是正常结果而非错误:合并提交默认不展开 diff、空提交也没有内容,
853
+ * 两者都返回 ok:true + 空字符串,由 UI 决定怎么展示。
854
+ */
855
+ async getCommitDiff(cwd, sha) {
856
+ if (typeof sha !== 'string' || !/^[0-9a-f]{4,40}$/i.test(sha)) {
857
+ return { ok: false, error: `非法的提交 SHA: "${sha}"(只允许十六进制短/全 SHA)` };
858
+ }
859
+ try {
860
+ const { stdout } = await execFileAsync('git', [
861
+ '-c', 'core.quotepath=false',
862
+ 'show',
863
+ '--format=',
864
+ '--no-color',
865
+ '--no-ext-diff',
866
+ '--src-prefix=a/',
867
+ '--dst-prefix=b/',
868
+ sha,
869
+ '--',
870
+ ], { cwd: cwd || undefined, timeout: 8_000, windowsHide: true, maxBuffer: 16 * 1024 * 1024 });
871
+ const raw = String(stdout);
872
+ const MAX = 400_000;
873
+ let patch = raw;
874
+ let truncated = false;
875
+ if (patch.length > MAX) {
876
+ truncated = true;
877
+ const boundary = patch.lastIndexOf('\ndiff --git ', MAX);
878
+ patch = boundary > 0 ? patch.slice(0, boundary) : patch.slice(0, MAX);
879
+ }
880
+ return { ok: true, sha, patch, chars: patch.length, truncated };
881
+ }
882
+ catch (err) {
883
+ // 优先回 stderr:sha 不存在(bad object)、不是本仓库等真实原因只在 stderr 里,
884
+ // execFile 的 message 只是 "Command failed: git show …" 的包装
885
+ const stderr = typeof err?.stderr === 'string' ? err.stderr.trim() : '';
886
+ return { ok: false, error: stderr || err?.message || '获取提交补丁失败' };
887
+ }
888
+ }
721
889
  /**
722
890
  * 纯本地读取工作区的所有 Git 分支(按提交时间倒序)。
723
891
  * 包含当前 HEAD 分支标记、关联 upstream、最新提交简述与相对时间。
@@ -741,12 +909,141 @@ export class GhExecutor {
741
909
  return null;
742
910
  }
743
911
  }
912
+ /**
913
+ * 发版输入:最近 tag + 该 tag 以来的提交(供 `github_task(prepare_release)` 使用)。
914
+ *
915
+ * 为什么要在 executor 里开这个口子,而不是让工具自己拼 git 命令:
916
+ * 命令形态必须集中(本文件顶部就是"git 白名单"纪律),否则每个新工具都会
917
+ * 自己发明一套 argv,`phase34` 的契约网也就管不到它们了。
918
+ *
919
+ * 安全性:两条命令的形态**全部固定**——`describe --tags --abbrev=0` 无参数注入;
920
+ * log 的修订范围段只拼 `git describe` 自己的输出,且先过 `/^[A-Za-z0-9._\/-]+$/`
921
+ * 白名单校验,杜绝把任意字符串当 revision 传进 git。
922
+ */
923
+ async getReleaseInputs(cwd, fromTagOverride) {
924
+ const empty = {
925
+ ok: false,
926
+ fromTag: null,
927
+ commits: [],
928
+ totalCommits: 0,
929
+ };
930
+ const runOpts = { cwd: cwd || undefined, timeout: 5_000, windowsHide: true };
931
+ let fromTag = null;
932
+ /*
933
+ * 显式指定的起始 tag 优先,且**必须真的作为 range 起点**。
934
+ *
935
+ * 为什么强调:只拿它当「版本号基数」而不改 range,会出现「按 v1.0.0 算版本、
936
+ * 但统计的是 v1.2.0 以来的提交」——数字与标签对不上,比直接报错更坏。
937
+ */
938
+ const override = typeof fromTagOverride === 'string' ? fromTagOverride.trim() : '';
939
+ if (override) {
940
+ /*
941
+ * 两条校验缺一不可:
942
+ * - 字符白名单挡住 `;` `$()` 这类命令注入;
943
+ * - **禁止以 `-` 开头**:它会被拼进 `git log <tag>..HEAD` 的第一个位置,
944
+ * git 会把 `-p..HEAD` 当成未知选项(实测报错)。虽然成不了真正的注入,
945
+ * 但「用户输入永远不许以 - 开头」是这条通道的基本纪律。
946
+ */
947
+ if (override.startsWith('-') || !/^[A-Za-z0-9._/-]+$/.test(override)) {
948
+ return { ...empty, error: `非法的 from_tag:${override}(只允许字母/数字/./_/-/,且不能以 - 开头)` };
949
+ }
950
+ fromTag = override;
951
+ }
952
+ else {
953
+ try {
954
+ const tagRes = await execFileAsync('git', ['describe', '--tags', '--abbrev=0'], runOpts);
955
+ const raw = String(tagRes.stdout || '').trim();
956
+ // 白名单:describe 的输出来自仓库自身,但仍要挡住 `..`、空白与 shell 元字符
957
+ if (raw && /^[A-Za-z0-9._/-]+$/.test(raw))
958
+ fromTag = raw;
959
+ }
960
+ catch {
961
+ // 没有任何 tag 是正常状态(新仓库),不是错误
962
+ fromTag = null;
963
+ }
964
+ }
965
+ try {
966
+ const range = fromTag ? `${fromTag}..HEAD` : 'HEAD';
967
+ const logRes = await execFileAsync('git',
968
+ // %b 带出提交正文:BREAKING CHANGE 常常只写在正文里,只看标题会漏判 major
969
+ ['log', range, '-n', '300', '--pretty=format:%x1e%h%x1f%s%x1f%an%x1f%ad%x1f%b'], runOpts);
970
+ const text = String(logRes.stdout || '');
971
+ const commits = text
972
+ .split('\x1e')
973
+ .map((chunk) => chunk.trim())
974
+ .filter(Boolean)
975
+ .map((chunk) => {
976
+ const parts = chunk.split('\x1f');
977
+ return {
978
+ sha: String(parts[0] || ''),
979
+ subject: String(parts[1] || ''),
980
+ author: String(parts[2] || ''),
981
+ date: String(parts[3] || ''),
982
+ // 正文只取前 500 字符:够判 BREAKING CHANGE,又不至于把整包撑大
983
+ body: String(parts.slice(4).join('\x1f') || '').slice(0, 500),
984
+ };
985
+ });
986
+ return { ok: true, fromTag, commits, totalCommits: commits.length };
987
+ }
988
+ catch (err) {
989
+ return { ...empty, fromTag, error: err?.message || '读取提交记录失败' };
990
+ }
991
+ }
992
+ /**
993
+ * 工作区干净度闸门(唯一实现)。
994
+ *
995
+ * 为什么抽成公共方法:会改变工作区分支/内容的入口有三条——界面切分支(switchGitBranch)、
996
+ * `github_pr(checkout)`、`github_issue(develop)`。此前只有第一条有脏检查,后两条直穿,
997
+ * 于是「强阻断」的成文承诺只对界面成立。安全不变量必须只有一处实现。
998
+ *
999
+ * 三态语义(fail-closed):
1000
+ * - `null`(git 读不到)→ 拒绝。读不到状态≠干净,放行等于闸门失效。
1001
+ * - `dirtyCount > 0` → 拒绝,并带前 5 条样例便于用户判断。
1002
+ * - 其余 → 放行。
1003
+ *
1004
+ * 超时为什么单独给 20s:`getLocalGitStatus` 默认 3s 是给概览面板用的(读不到就不渲染那一行),
1005
+ * 但这里是一次安全判决——大型仓库 / 网络盘 / OneDrive 同步目录下 `git status` 冷启动超 3s
1006
+ * 很常见,用 3s 会把"慢"误判成"脏/不可用",直接把 checkout 挡死。
1007
+ */
1008
+ async ensureCleanWorkspace(cwd) {
1009
+ const targetCwd = this.resolveWorkspaceCwd(cwd);
1010
+ // 先区分"根本不是 Git 仓库"与"是仓库但读不到状态"——两者对用户的可操作性完全不同。
1011
+ if (!hasGitDir(targetCwd)) {
1012
+ return {
1013
+ ok: false,
1014
+ reason: 'unknown',
1015
+ dirtyCount: 0,
1016
+ samples: [],
1017
+ error: `当前目录不是 Git 仓库(${targetCwd}),无法执行需要切换分支的操作。`,
1018
+ };
1019
+ }
1020
+ const gitSt = await this.getLocalGitStatus(targetCwd, 20_000);
1021
+ if (gitSt === null) {
1022
+ return {
1023
+ ok: false,
1024
+ reason: 'unknown',
1025
+ dirtyCount: 0,
1026
+ samples: [],
1027
+ error: '读取工作区 Git 状态失败(git 不可用、超时或权限不足)。为避免覆盖本地改动,已拒绝执行。',
1028
+ };
1029
+ }
1030
+ if (gitSt.dirtyCount > 0) {
1031
+ return {
1032
+ ok: false,
1033
+ reason: 'dirty',
1034
+ dirtyCount: gitSt.dirtyCount,
1035
+ samples: (gitSt.dirtyFiles || []).slice(0, 5).map((f) => `${f.status} ${f.path}`),
1036
+ error: `工作区有 ${gitSt.dirtyCount} 个未提交的文件变更。为防止代码被覆盖,请先提交或保存后再执行。`,
1037
+ };
1038
+ }
1039
+ return { ok: true, dirtyCount: 0, samples: [] };
1040
+ }
744
1041
  /**
745
1042
  * 安全切换当前工作区的 Git 分支。
746
1043
  * 三重安全防御纪律:
747
1044
  * 1. 目标工作区必须存在有效的 .git 目录;
748
1045
  * 2. 分支名称严格执行字符白名单校验,防命令行参数注入;
749
- * 3. 检查工作区干净度(dirtyCount),若有未提交或未保存的变更直接阻断,杜绝代码被破坏性覆盖。
1046
+ * 3. 工作区干净度交给 ensureCleanWorkspace(唯一实现,与 checkout / develop 共用)。
750
1047
  */
751
1048
  async switchGitBranch(cwd, branchName) {
752
1049
  const targetCwd = this.resolveWorkspaceCwd(cwd);
@@ -759,12 +1056,9 @@ export class GhExecutor {
759
1056
  return { ok: false, error: '非法分支名称,仅允许字母、数字、短横线、下划线与斜杠组合' };
760
1057
  }
761
1058
  // 防线二:工作区未提交变更阻断(Dirty File Guard)
762
- const gitSt = await this.getLocalGitStatus(targetCwd);
763
- if (gitSt && gitSt.dirtyCount > 0) {
764
- return {
765
- ok: false,
766
- error: `工作区有 ${gitSt.dirtyCount} 个未提交的文件变更。为防止代码被覆盖,请先提交或保存后再切换分支。`,
767
- };
1059
+ const guard = await this.ensureCleanWorkspace(targetCwd);
1060
+ if (!guard.ok) {
1061
+ return { ok: false, error: guard.error };
768
1062
  }
769
1063
  try {
770
1064
  let args = ['checkout', cleanBranch];
@@ -890,8 +1184,8 @@ export class GhExecutor {
890
1184
  if (!num || isNaN(num) || num <= 0) {
891
1185
  return { ok: false, error: '缺少有效 PR 编号(必须为正整数)' };
892
1186
  }
893
- const cleanRepo = typeof repo === 'string' ? repo.trim() : '';
894
- if (cleanRepo && !/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(cleanRepo)) {
1187
+ const cleanRepo = this.normalizeRepoFullName(repo);
1188
+ if (cleanRepo === null) {
895
1189
  return { ok: false, error: '非法仓库全名参数(仅允许 owner/repo 格式)' };
896
1190
  }
897
1191
  const viewArgs = [
@@ -899,7 +1193,10 @@ export class GhExecutor {
899
1193
  'view',
900
1194
  String(num),
901
1195
  '--json',
902
- 'number,title,state,isDraft,url,createdAt,updatedAt,author,baseRefName,headRefName,body,reviewDecision,statusCheckRollup,comments,reviews,files,additions,deletions',
1196
+ // headRefOid/baseRefOid:分支会移动,SHA 才是可钉住的审查锚点(s7 §2.1)
1197
+ // changedFiles:`--json files` 在 100 条处静默截断,只有这个数字能判定「清单被截了」
1198
+ // headRepositoryOwner:`prDetail.headRepoOwner` 的取值来源(此前恒 undefined)
1199
+ 'number,title,state,isDraft,url,createdAt,updatedAt,author,baseRefName,headRefName,headRefOid,baseRefOid,changedFiles,headRepositoryOwner,body,reviewDecision,statusCheckRollup,comments,reviews,files,additions,deletions',
903
1200
  ];
904
1201
  if (cleanRepo)
905
1202
  viewArgs.push('-R', cleanRepo);
@@ -963,6 +1260,24 @@ export class GhExecutor {
963
1260
  }
964
1261
  const fullDiff = rDiff.ok ? (typeof rDiff.data === 'string' ? rDiff.data : rDiff.rawOutput || '') : '';
965
1262
  const fileDiffs = splitUnifiedDiff(fullDiff);
1263
+ const files = Array.isArray(raw.files)
1264
+ ? raw.files.map((f) => ({
1265
+ path: String(f.path || ''),
1266
+ additions: Number(f.additions) || 0,
1267
+ deletions: Number(f.deletions) || 0,
1268
+ changeType: String(f.changeType || 'MODIFIED'),
1269
+ }))
1270
+ : [];
1271
+ /*
1272
+ * 三个截断字段各自回答一个不同问题,缺一不可(s7 §2.5):
1273
+ * - changedFiles :仓库里这个 PR 到底改了多少个文件(权威总数)
1274
+ * - filesTruncated :清单被 gh 的 100 条上限裁掉了吗(cli/cli#12522 静默截断)
1275
+ * - diffIncomplete :清单里有、但切片里没有对应 diff 的文件(数据缺失,不是「没改动」)
1276
+ * 把三者混成一个布尔值,界面就只能说「可能不全」,用户无法判断该去做什么。
1277
+ */
1278
+ const changedFiles = Number(raw.changedFiles) || files.length;
1279
+ const filesTruncated = files.length < changedFiles;
1280
+ const diffIncomplete = files.some((f) => !fileDiffs[f.path]);
966
1281
  const prDetail = {
967
1282
  number: Number(raw.number) || num,
968
1283
  title: String(raw.title || ''),
@@ -977,20 +1292,18 @@ export class GhExecutor {
977
1292
  },
978
1293
  headRefName: String(raw.headRefName || ''),
979
1294
  baseRefName: String(raw.baseRefName || ''),
1295
+ headRefOid: raw.headRefOid ? String(raw.headRefOid) : undefined,
1296
+ baseRefOid: raw.baseRefOid ? String(raw.baseRefOid) : undefined,
980
1297
  headRepoOwner: raw.headRepositoryOwner?.login ? String(raw.headRepositoryOwner.login) : undefined,
981
1298
  body: String(raw.body || ''),
982
1299
  reviewDecision: raw.reviewDecision ? String(raw.reviewDecision) : undefined,
983
1300
  statusCheckRollup: raw.statusCheckRollup,
984
1301
  additions: Number(raw.additions) || 0,
985
1302
  deletions: Number(raw.deletions) || 0,
986
- files: Array.isArray(raw.files)
987
- ? raw.files.map((f) => ({
988
- path: String(f.path || ''),
989
- additions: Number(f.additions) || 0,
990
- deletions: Number(f.deletions) || 0,
991
- changeType: String(f.changeType || 'MODIFIED'),
992
- }))
993
- : [],
1303
+ files,
1304
+ changedFiles,
1305
+ ...(filesTruncated ? { filesTruncated: true } : {}),
1306
+ ...(diffIncomplete ? { diffIncomplete: true } : {}),
994
1307
  comments: Array.isArray(raw.comments)
995
1308
  ? raw.comments.map((c) => ({
996
1309
  id: String(c.id || ''),
@@ -1019,6 +1332,92 @@ export class GhExecutor {
1019
1332
  };
1020
1333
  return { ok: true, data: prDetail };
1021
1334
  }
1335
+ /**
1336
+ * 提交带行级评注的 PR review(REST 通道)。
1337
+ *
1338
+ * 为什么必须走 REST:`gh pr review` 只有 `--approve/--request-changes/--comment`,
1339
+ * 没有任何行级参数(`gh pr review --help` 里 line/side/path 零命中)。行级评注
1340
+ * 的唯一入口是 `POST /repos/{owner}/{repo}/pulls/{n}/reviews` 的 `comments[]`。
1341
+ *
1342
+ * 为什么一次 review 一个请求(而不是逐条 `POST /pulls/{n}/comments`):
1343
+ * 通知只发一轮,也避开 secondary rate limit 的「创建内容过快」判定。
1344
+ *
1345
+ * 422 降级:行级评注被拒(典型原因:该行不在 diff 的可评论侧,如纯删除行)
1346
+ * 时,把评注折叠进 review 正文重试一次。内容不能丢,但降级必须显式可见。
1347
+ */
1348
+ async createPrReview(cwd, repo, prNumber, input) {
1349
+ const targetCwd = this.resolveWorkspaceCwd(cwd);
1350
+ const num = Number(prNumber);
1351
+ if (!num || isNaN(num) || num <= 0) {
1352
+ return { ok: false, error: '缺少有效 PR 编号(必须为正整数)' };
1353
+ }
1354
+ // 仓库名优先用显式参数;缺省时从本地 git remote 推导(与 getPrDetail 同一套规则)
1355
+ const cleanRepo = this.normalizeRepoFullName(repo);
1356
+ if (cleanRepo === null) {
1357
+ return { ok: false, error: '非法仓库全名参数(仅允许 owner/repo 格式)' };
1358
+ }
1359
+ const fullName = cleanRepo || this.getRepoIdentityFromLocal(targetCwd)?.nameWithOwner || '';
1360
+ if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(fullName)) {
1361
+ return { ok: false, error: '无法确定仓库全名:请传 repo 参数,或在 Git 仓库目录下调用' };
1362
+ }
1363
+ const built = buildPrReviewPayload(input);
1364
+ if (!built.ok || !built.payload) {
1365
+ return { ok: false, error: `评注参数校验未通过:\n- ${(built.errors || ['未知错误']).join('\n- ')}` };
1366
+ }
1367
+ const payload = built.payload;
1368
+ const endpoint = `repos/${fullName}/pulls/${num}/reviews`;
1369
+ const post = (body) => this.run(['api', '--method', 'POST', endpoint, '--input', '-'], {
1370
+ cwd: targetCwd,
1371
+ timeoutMs: 20_000,
1372
+ stdin: JSON.stringify(body),
1373
+ });
1374
+ const first = await post(payload);
1375
+ if (first.ok) {
1376
+ const data = first.data;
1377
+ return {
1378
+ ok: true,
1379
+ data: {
1380
+ id: data?.id,
1381
+ htmlUrl: data?.html_url,
1382
+ state: data?.state,
1383
+ submittedAt: data?.submitted_at,
1384
+ // 逐条回传评论地址,供人复核「到底挂到了哪一行」
1385
+ comments: Array.isArray(data?.comments)
1386
+ ? data.comments.map((c) => ({
1387
+ path: String(c?.path || ''),
1388
+ line: typeof c?.line === 'number' ? c.line : undefined,
1389
+ htmlUrl: c?.html_url ? String(c.html_url) : undefined,
1390
+ }))
1391
+ : [],
1392
+ },
1393
+ };
1394
+ }
1395
+ // 只在「行级评注被拒」时降级;403/404/网络错误原样抛出(降级也没用)
1396
+ const hasComments = Array.isArray(payload.comments) && payload.comments.length > 0;
1397
+ if (hasComments && isLineCommentRejected(first.error || '')) {
1398
+ const folded = foldCommentsIntoBody(payload.comments, payload.body);
1399
+ const retry = await post({ ...payload, body: folded, comments: undefined });
1400
+ // 把 GitHub 的原始拒绝原因带进说明里:否则用户只看到「已折叠进正文」,
1401
+ // 却不知道是「行不在 diff 可评论侧」还是「path 根本不存在」——两者处置完全不同。
1402
+ const why = String(first.error || '').replace(/\s+/g, ' ').trim().slice(0, 200);
1403
+ if (retry.ok) {
1404
+ const data = retry.data;
1405
+ return {
1406
+ ok: true,
1407
+ degraded: true,
1408
+ degradedReason: `行级评注被 GitHub 拒绝(${why}),已折叠进 review 正文提交,内容未丢失。`,
1409
+ data: { id: data?.id, htmlUrl: data?.html_url, state: data?.state, submittedAt: data?.submitted_at, comments: [] },
1410
+ };
1411
+ }
1412
+ return {
1413
+ ok: false,
1414
+ degraded: true,
1415
+ degradedReason: `行级评注被拒(${why})后降级重试仍失败。`,
1416
+ error: retry.error || first.error,
1417
+ };
1418
+ }
1419
+ return { ok: false, error: first.error };
1420
+ }
1022
1421
  /**
1023
1422
  * 获取 Issue 完整详情(供 IssueDetailModal 在线查看消费)。
1024
1423
  *
@@ -1035,8 +1434,8 @@ export class GhExecutor {
1035
1434
  if (!num || isNaN(num) || num <= 0 || !Number.isInteger(num)) {
1036
1435
  return { ok: false, error: '缺少有效 Issue 编号(必须为正整数)' };
1037
1436
  }
1038
- const cleanRepo = typeof repo === 'string' ? repo.trim() : '';
1039
- if (cleanRepo && !/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(cleanRepo)) {
1437
+ const cleanRepo = this.normalizeRepoFullName(repo);
1438
+ if (cleanRepo === null) {
1040
1439
  return { ok: false, error: '非法仓库全名参数(仅允许 owner/repo 格式)' };
1041
1440
  }
1042
1441
  const buildArgs = (fields) => {
@@ -1166,6 +1565,249 @@ export class GhExecutor {
1166
1565
  };
1167
1566
  return { ok: true, data: detail };
1168
1567
  }
1568
+ /**
1569
+ * 详情类接口的仓库参数归一化(PR / Issue / Run 共用)。
1570
+ *
1571
+ * 返回 `''` = 未传(由本地 remote 决定);返回 `null` = 非法。
1572
+ *
1573
+ * 为什么要收编三处各自的校验:历史实现各写一份正则,只有 `setDefaultRepo` 额外
1574
+ * 拒绝 `-` 开头与 `..`,其余靠「恒以 `-R <value>` 成对传入」兜底(复核报告 C-13)。
1575
+ * 「成对传入所以安全」是当前调用形状的安全,不是接口的安全——多一处调用方就多
1576
+ * 一次赌。统一到这里后,`-` 开头/`..`/反斜杠在任何详情入口都进不来。
1577
+ */
1578
+ normalizeRepoFullName(repo) {
1579
+ const clean = typeof repo === 'string' ? repo.trim() : '';
1580
+ if (!clean)
1581
+ return '';
1582
+ if (clean.startsWith('-') || clean.includes('..') || clean.includes('\\'))
1583
+ return null;
1584
+ if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(clean))
1585
+ return null;
1586
+ return clean;
1587
+ }
1588
+ /**
1589
+ * 获取单次 Actions Run 的完整详情(run + jobs + steps)。
1590
+ *
1591
+ * 架构设计:
1592
+ * 1. **run_id 只认纯数字**。它会被当作 `gh run view` 的位置参数拼进 argv,
1593
+ * 不做白名单就等于把「用参数值注入 flag」这条已知缺陷(复核报告 C-01)
1594
+ * 复制到新通道——`--web`、`--log` 都能冒充编号。
1595
+ * 2. 单次往返拿全:`jobs` 里已经包含 steps 树,没有需要并行拉取的第二路。
1596
+ * 3. **日志不在这里拉**。实测单次失败日志 4–40 秒(本机代理下还直接连接重置),
1597
+ * 挂在详情上等于让弹窗首屏去赌网络;日志走 getRunLogExcerpts() 按需拉。
1598
+ */
1599
+ async getRunDetail(cwd, runId, repo) {
1600
+ const targetCwd = this.resolveWorkspaceCwd(cwd);
1601
+ const id = String(runId ?? '').trim();
1602
+ if (!/^\d+$/.test(id) || Number(id) <= 0) {
1603
+ return { ok: false, error: '缺少有效 Run ID(必须为正整数)' };
1604
+ }
1605
+ const cleanRepo = this.normalizeRepoFullName(repo);
1606
+ if (cleanRepo === null) {
1607
+ return { ok: false, error: '非法仓库全名参数(仅允许 owner/repo 格式)' };
1608
+ }
1609
+ const buildArgs = (fields) => {
1610
+ const args = ['run', 'view', id, '--json', fields];
1611
+ if (cleanRepo)
1612
+ args.push('-R', cleanRepo);
1613
+ return args;
1614
+ };
1615
+ /**
1616
+ * 输出阈值必须显式放宽。
1617
+ *
1618
+ * 为什么:矩阵构建的 jobs 数组动辄上百个 job、每个十几步,JSON 轻松过 100KB;
1619
+ * 沿用默认 24KB 会跳过 JSON.parse,data 恒为 undefined,于是弹窗渲染出
1620
+ * 「元数据全空 + 零 job」的假详情——比报错更糟(同 getIssueDetail 的教训)。
1621
+ */
1622
+ const execOpts = {
1623
+ cwd: targetCwd,
1624
+ timeoutMs: 20_000,
1625
+ maxOutputChars: 2_000_000,
1626
+ maxOutputBytes: 8 * 1024 * 1024,
1627
+ };
1628
+ let fieldsDegraded = false;
1629
+ let r = await this.run(buildArgs(RUN_DETAIL_FULL_FIELDS), execOpts);
1630
+ if (!r.ok && /Unknown JSON field/i.test(String(r.error || ''))) {
1631
+ fieldsDegraded = true;
1632
+ r = await this.run(buildArgs(RUN_DETAIL_BASE_FIELDS), execOpts);
1633
+ }
1634
+ if (!r.ok) {
1635
+ return { ok: false, error: r.error || `获取 Run #${id} 详情失败` };
1636
+ }
1637
+ // 截断 / 解析失败必须显式失败:渲染空 jobs 会被读成「这次运行没有 job」
1638
+ if (r.truncated || !r.data || typeof r.data !== 'object') {
1639
+ const link = cleanRepo ? `https://github.com/${cleanRepo}/actions/runs/${id}` : '仓库的 Actions 页面';
1640
+ return {
1641
+ ok: false,
1642
+ error: `Run #${id} 的 job 清单过长,已超出插件单次读取上限。请在浏览器中查看完整运行(${link})。`,
1643
+ };
1644
+ }
1645
+ const raw = r.data;
1646
+ const jobs = Array.isArray(raw.jobs)
1647
+ ? raw.jobs
1648
+ .filter((j) => j && (j.databaseId || j.name))
1649
+ .map((j) => {
1650
+ const steps = Array.isArray(j.steps)
1651
+ ? j.steps
1652
+ .filter((s) => s && typeof s === 'object')
1653
+ .map((s) => ({
1654
+ number: Number(s.number) || 0,
1655
+ name: String(s.name || ''),
1656
+ status: String(s.status || ''),
1657
+ ...(s.conclusion ? { conclusion: String(s.conclusion) } : {}),
1658
+ ...(s.startedAt ? { startedAt: String(s.startedAt) } : {}),
1659
+ ...(s.completedAt ? { completedAt: String(s.completedAt) } : {}),
1660
+ }))
1661
+ : [];
1662
+ return {
1663
+ databaseId: Number(j.databaseId) || 0,
1664
+ name: String(j.name || '未命名 job'),
1665
+ status: String(j.status || ''),
1666
+ ...(j.conclusion ? { conclusion: String(j.conclusion) } : {}),
1667
+ ...(j.startedAt ? { startedAt: String(j.startedAt) } : {}),
1668
+ ...(j.completedAt ? { completedAt: String(j.completedAt) } : {}),
1669
+ ...(j.url ? { url: String(j.url) } : {}),
1670
+ steps,
1671
+ };
1672
+ })
1673
+ : [];
1674
+ // 失败口径只算一次,UI 直接消费(避免前端再推导出第二套口径)
1675
+ const failedJobs = jobs.filter((j) => j.conclusion === 'failure').length;
1676
+ const failedSteps = jobs.reduce((sum, j) => sum + j.steps.filter((s) => s.conclusion === 'failure').length, 0);
1677
+ const detail = {
1678
+ runId: Number(raw.databaseId) || Number(id),
1679
+ ...(typeof raw.attempt === 'number' && raw.attempt > 0 ? { attempt: raw.attempt } : {}),
1680
+ name: String(raw.name || raw.workflowName || `Run ${id}`),
1681
+ ...(raw.displayTitle ? { displayTitle: String(raw.displayTitle) } : {}),
1682
+ status: String(raw.status || ''),
1683
+ ...(raw.conclusion ? { conclusion: String(raw.conclusion) } : {}),
1684
+ ...(raw.event ? { event: String(raw.event) } : {}),
1685
+ ...(raw.headBranch ? { headBranch: String(raw.headBranch) } : {}),
1686
+ ...(raw.headSha ? { headSha: String(raw.headSha) } : {}),
1687
+ url: String(raw.url || ''),
1688
+ createdAt: String(raw.createdAt || ''),
1689
+ ...(raw.startedAt ? { startedAt: String(raw.startedAt) } : {}),
1690
+ updatedAt: String(raw.updatedAt || ''),
1691
+ ...(raw.workflowName ? { workflowName: String(raw.workflowName) } : {}),
1692
+ jobs,
1693
+ failedJobs,
1694
+ failedSteps,
1695
+ ...(fieldsDegraded ? { fieldsDegraded: true } : {}),
1696
+ };
1697
+ return { ok: true, data: detail };
1698
+ }
1699
+ /**
1700
+ * 按需拉取失败日志并压成纯文本摘要(供 Run 详情弹窗消费)。
1701
+ *
1702
+ * 与 github_run(log_failed) 的关系:同一份 `--log-failed` 原始输出、同一套锚点
1703
+ * 萃取与尾部回退,只是排版不同(弹窗要的是 `<pre>` 纯文本,不是 Markdown)。
1704
+ *
1705
+ * 为什么逐 job 拉而不是整 run 一次拉:`gh run view --log-failed` 会把**整次运行**
1706
+ * 的日志包都下载下来再过滤,矩阵构建下等于为了一个失败 job 拖几十 MB;`-j <jobId>`
1707
+ * 是 gh 支持的定向形态(见 `gh run view --help`),失败 job 通常只有 1–3 个。
1708
+ *
1709
+ * 为什么单个 job 失败仍返回 ok:true:部分成功比整体失败有用——已拿到的块照常展示,
1710
+ * 失败的那个进 errors[] 并在界面上显式可重试。只有参数非法才是 ok:false。
1711
+ */
1712
+ async getRunLogExcerpts(cwd, runId, jobIds, repo,
1713
+ /**
1714
+ * 本批日志的总预算(毫秒,默认 `RUN_LOG_TOTAL_BUDGET_MS`)。
1715
+ *
1716
+ * 为什么需要:单 job 超时 60s × 最多 3 个串行 = 最坏 180s。这个调用挂在 HTTP
1717
+ * 路由上,而宿主 WebServer 是全 GUI 共享的——一次点击占住三分钟不可接受。
1718
+ * 超预算的 job 进 `errors[]`(显式、可单独重试),而不是让连接一直挂着。
1719
+ * 允许外部传入只为可测:传 0 能验证「超预算」分支而不必真的等待。
1720
+ */
1721
+ opts = {}) {
1722
+ const targetCwd = this.resolveWorkspaceCwd(cwd);
1723
+ const id = String(runId ?? '').trim();
1724
+ if (!/^\d+$/.test(id) || Number(id) <= 0) {
1725
+ return { ok: false, error: '缺少有效 Run ID(必须为正整数)' };
1726
+ }
1727
+ const cleanRepo = this.normalizeRepoFullName(repo);
1728
+ if (cleanRepo === null) {
1729
+ return { ok: false, error: '非法仓库全名参数(仅允许 owner/repo 格式)' };
1730
+ }
1731
+ const wanted = Array.from(new Set((Array.isArray(jobIds) ? jobIds : [])
1732
+ .map((v) => String(v ?? '').trim())
1733
+ .filter((v) => /^\d+$/.test(v) && Number(v) > 0)));
1734
+ if (wanted.length === 0) {
1735
+ return { ok: false, error: '缺少有效 job_id(必须为正整数,最多 3 个)' };
1736
+ }
1737
+ // 超上限必须显式拒绝:静默 slice 会让调用方以为 5 个 job 的日志都拉了,
1738
+ // 与仓库自己立的「截断必须可见」纪律(filesTruncated / totalUnknown)相悖。
1739
+ if (wanted.length > RUN_LOG_MAX_JOBS) {
1740
+ return {
1741
+ ok: false,
1742
+ error: `一次最多拉取 ${RUN_LOG_MAX_JOBS} 个 job 的失败日志(本次请求 ${wanted.length} 个)。请分批重试,或直接在 GitHub 查看完整日志。`,
1743
+ };
1744
+ }
1745
+ // 名字从详情树里取(job id → 名称)。取不到不影响拉日志,但**必须显式标注**:
1746
+ // 名字退化成 `job #111` 是可见的降级,不能让它悄悄发生(本仓的降级纪律)。
1747
+ const detail = await this.getRunDetail(targetCwd, id, cleanRepo || undefined).catch(() => null);
1748
+ const nameById = new Map();
1749
+ for (const job of detail && detail.ok && detail.data ? detail.data.jobs : []) {
1750
+ if (job.databaseId > 0)
1751
+ nameById.set(job.databaseId, job.name);
1752
+ }
1753
+ const namesUnavailable = wanted.some((jobId) => !nameById.has(Number(jobId)));
1754
+ const jobs = [];
1755
+ const errors = [];
1756
+ // 总预算:3 个 job 各 60s 串行会把一条 HTTP 请求拖到 180s(见方法注释)
1757
+ const budget = Number.isFinite(opts.totalBudgetMs)
1758
+ ? Math.max(0, Math.floor(opts.totalBudgetMs))
1759
+ : RUN_LOG_TOTAL_BUDGET_MS;
1760
+ const deadline = Date.now() + budget;
1761
+ // 串行而非并发:每次都是一次整包下载,并发只会同时挤爆代理与 gh 缓存目录
1762
+ for (const jobId of wanted) {
1763
+ const jobName = nameById.get(Number(jobId)) || `job #${jobId}`;
1764
+ // 定向日志通道:`-j <jobId> --log-failed` 只下这个 job 的失败步骤日志
1765
+ const logArgs = ['run', 'view', id, '-j', jobId, '--log-failed'];
1766
+ if (cleanRepo)
1767
+ logArgs.push('-R', cleanRepo);
1768
+ // 预算在**每次**调用前重算:前一个 job 慢,后面的就该直接判超预算而不是再赌一次
1769
+ const remaining = deadline - Date.now();
1770
+ if (remaining <= 0) {
1771
+ errors.push({
1772
+ jobName,
1773
+ error: `超出本批日志总预算(${Math.round(budget / 1000)}s),该 job 未拉取。请单独重试。`,
1774
+ });
1775
+ continue;
1776
+ }
1777
+ const res = await this.run(logArgs, {
1778
+ cwd: targetCwd,
1779
+ // 单个 job 最多 60s,但绝不越过本批总预算
1780
+ timeoutMs: Math.min(60_000, remaining),
1781
+ rawText: true,
1782
+ maxOutputChars: 2_000_000,
1783
+ maxOutputBytes: 8 * 1024 * 1024,
1784
+ });
1785
+ if (!res.ok) {
1786
+ const friendly = interpretRunLogError(String(res.error || ''), id);
1787
+ if (friendly) {
1788
+ // 确定性结论(该 job 没有失败步骤 / 缓存目录无权限)不是故障,进正文而非 errors
1789
+ jobs.push({ jobId: Number(jobId), jobName, excerpt: friendly, truncated: false });
1790
+ }
1791
+ else {
1792
+ errors.push({ jobName, error: String(res.error || '日志拉取失败').trim().slice(0, 400) });
1793
+ }
1794
+ continue;
1795
+ }
1796
+ const built = buildPlainFailureExcerpt(res.rawOutput || '', {
1797
+ budget: RUN_LOG_BUDGET_CHARS,
1798
+ truncated: Boolean(res.truncated),
1799
+ });
1800
+ jobs.push({ jobId: Number(jobId), jobName, excerpt: built.text, truncated: built.truncated });
1801
+ }
1802
+ const data = {
1803
+ runId: Number(id),
1804
+ jobs,
1805
+ ...(errors.length > 0 ? { errors } : {}),
1806
+ ...(namesUnavailable ? { namesUnavailable: true } : {}),
1807
+ fetchedAt: new Date().toISOString(),
1808
+ };
1809
+ return { ok: true, data };
1810
+ }
1169
1811
  /** 生成当前操作系统平台的推荐安装引导与命令 */
1170
1812
  getPlatformInstallGuide() {
1171
1813
  return getPlatformInstallGuide();