dsh-github-flow 0.6.0 → 0.7.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 (83) hide show
  1. package/README.en.md +98 -15
  2. package/README.md +101 -15
  3. package/docs/UPGRADE-NOTES.md +12 -11
  4. package/docs/architecture.md +56 -13
  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 +75 -1
  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 +1585 -429
  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 +156 -3
  29. package/lib/executor.js +594 -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.js +4 -4
  49. package/lib/tools/api.js +18 -6
  50. package/lib/tools/cache.js +42 -40
  51. package/lib/tools/codespace.js +4 -12
  52. package/lib/tools/discussion.js +4 -4
  53. package/lib/tools/gist.d.ts +1 -39
  54. package/lib/tools/gist.js +4 -14
  55. package/lib/tools/help.js +52 -13
  56. package/lib/tools/issue.js +16 -28
  57. package/lib/tools/label.js +4 -15
  58. package/lib/tools/pr.d.ts +47 -0
  59. package/lib/tools/pr.js +170 -33
  60. package/lib/tools/project.js +4 -13
  61. package/lib/tools/release.js +11 -22
  62. package/lib/tools/repo.js +4 -4
  63. package/lib/tools/ruleset.js +9 -6
  64. package/lib/tools/run.js +76 -24
  65. package/lib/tools/secret.js +11 -17
  66. package/lib/tools/skill.js +18 -22
  67. package/lib/tools/status.js +4 -4
  68. package/lib/tools/task.d.ts +219 -0
  69. package/lib/tools/task.js +457 -0
  70. package/lib/types.d.ts +183 -1
  71. package/lib/ui/help-modal.d.ts +1 -1
  72. package/lib/ui/help-modal.js +20 -10
  73. package/lib/ui/issue-modal.js +7 -3
  74. package/lib/ui/pr-modal.js +50 -11
  75. package/lib/ui/run-modal.d.ts +26 -0
  76. package/lib/ui/run-modal.js +486 -0
  77. package/lib/ui/styles.d.ts +1 -1
  78. package/lib/ui/styles.js +370 -44
  79. package/lib/ui/sync-settings-modal.js +1 -1
  80. package/lib/ui/workspace-panel.js +78 -23
  81. package/lib/version.d.ts +1 -1
  82. package/lib/version.js +1 -1
  83. 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,51 @@ 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
+ resolveExecScope(execContext) {
241
+ const sessionId = execContext?.sessionId || execContext?.session?.id || process.env.DSH_SESSION_ID;
242
+ // 1) 显式 cwd 原样透传:这是唯一事实源,不做存在性替换,也不许被会话反查覆盖
243
+ if (execContext?.cwd)
244
+ return { cwd: execContext.cwd, sessionId };
245
+ // 2) 按会话反查真实工作区(旧实现被 process.cwd() 短路掉的那一级)
246
+ if (sessionId) {
247
+ const fromSession = findWorkspacePathBySession(sessionId);
248
+ if (fromSession)
249
+ return { cwd: fromSession, sessionId };
250
+ }
251
+ // 3) 兜底到进程目录。**不**调用 findMostRecentWorkspacePath()
252
+ return { cwd: process.cwd(), sessionId };
253
+ }
183
254
  /**
184
255
  * 安全执行 gh 命令
185
256
  * 采用 execFile 参数数组隔离,彻底杜绝 Shell 字符串拼接注入漏洞
186
257
  */
187
258
  async run(args, options = {}) {
259
+ /*
260
+ * gh 子进程调用计数(只增不清零)。
261
+ *
262
+ * 为什么放在 executor 而不是各工具里手数:任务原语(github_task)必须能如实
263
+ * 汇报 `cost.ghCalls`,而「原语是否真的省钱」是它存在的唯一判据。
264
+ * 手写计数必然与实际调用漂移——真实调用点只有这里一个。
265
+ */
266
+ this.ghCalls++;
188
267
  const cwd = this.resolveWorkspaceCwd(options.cwd, options.sessionId);
189
268
  const timeout = options.timeoutMs || this.defaultTimeoutMs;
190
269
  // 二进制模式(如 gh api 下载图片字节流):stdout 保持 Buffer,绕过 UTF-8
@@ -248,7 +327,15 @@ export class GhExecutor {
248
327
  if (!rawOutput && errText) {
249
328
  rawOutput = errText.trim();
250
329
  }
251
- const outcome = truncateOutput(rawOutput, options.maxOutputChars ?? this.maxOutputChars, options.maxOutputBytes ?? this.maxOutputBytes);
330
+ const maxChars = options.maxOutputChars ?? this.maxOutputChars;
331
+ // 字节上限联动推导:若调用方显式放宽了字符上限(如放宽到 1MB),未指定 maxOutputBytes 时
332
+ // 必须按 4 倍自动放宽,绝不能死板回落到全局实例的 96KB(否则 >96KB 的输出会被静默判定截断)。
333
+ // 取 Math.max 是为了不压低用户在 Config 里配置的更大 maxOutputBytes。
334
+ const maxBytes = options.maxOutputBytes ??
335
+ (options.maxOutputChars
336
+ ? Math.max(options.maxOutputChars * 4, this.maxOutputBytes)
337
+ : this.maxOutputBytes);
338
+ const outcome = truncateOutput(rawOutput, maxChars, maxBytes);
252
339
  rawOutput = outcome.text;
253
340
  const truncated = outcome.truncated;
254
341
  // 如果未被截断且命令带有 --json 或包含 api,尝试解析为 JSON
@@ -425,18 +512,20 @@ export class GhExecutor {
425
512
  * 零网络:status 基于 remote-tracking ref(上次 fetch 的快照),毫秒级本地读取,
426
513
  * 符合 global-overview 端点「工作区探测不发网络」的性能红线。
427
514
  */
428
- async getLocalGitStatus(cwd) {
515
+ async getLocalGitStatus(cwd, timeoutMs = 3_000) {
429
516
  try {
430
- // 纯本地毫秒级;3s 超时兜底异常文件系统
517
+ // 默认 3s:概览面板只是"读不到就不渲染这一行"的兜底语义。
518
+ // 但安全闸门(ensureCleanWorkspace)必须显式传更长超时——大型仓库 / 网络盘上
519
+ // `git status` 冷启动超 3s 很常见,用 3s 会把"读不到"误判成"有风险"。
431
520
  const { stdout } = await execFileAsync('git', ['status', '-sb', '--porcelain'], {
432
521
  cwd: cwd || undefined,
433
- timeout: 3_000,
522
+ timeout: timeoutMs,
434
523
  windowsHide: true,
435
524
  });
436
525
  return parseGitStatusOutput(String(stdout), 2000);
437
526
  }
438
527
  catch {
439
- // 非 Git 目录 / git 未安装 / 超时:静默降级为 null(调用方不渲染该行)
528
+ // 非 Git 目录 / git 未安装 / 超时:静默降级为 null(调用方自行决定语义)
440
529
  return null;
441
530
  }
442
531
  }
@@ -486,7 +575,9 @@ export class GhExecutor {
486
575
  }
487
576
  try {
488
577
  // 1. 先用 porcelain status 获取该文件的状态码
489
- let fileStatus = 'M';
578
+ // 默认值必须为空:旧实现默认 'M',导致工作区干净的文件被报成「已修改」
579
+ // (UI 表现为"文件被改了,但 diff 是空的")。状态只从 porcelain 结果推导。
580
+ let fileStatus = '';
490
581
  try {
491
582
  const { stdout: statusOut } = await execFileAsync('git', ['status', '--porcelain', '--', cleanRel], {
492
583
  cwd: targetCwd || undefined,
@@ -582,7 +673,7 @@ export class GhExecutor {
582
673
  return {
583
674
  ok: true,
584
675
  filePath: cleanRel,
585
- status: fileStatus,
676
+ status: fileStatus || 'M',
586
677
  isBinary: true,
587
678
  diff: 'Binary files differ',
588
679
  additions: 0,
@@ -599,7 +690,8 @@ export class GhExecutor {
599
690
  return {
600
691
  ok: true,
601
692
  filePath: cleanRel,
602
- status: fileStatus,
693
+ // porcelain 没给状态且没有 diff → 该文件其实是干净的,显式标 'clean' 而不是伪造成 'M'
694
+ status: fileStatus || (finalDiff ? 'M' : 'clean'),
603
695
  diff: finalDiff || '没有检测到文本差异',
604
696
  additions: diffStats.additions,
605
697
  deletions: diffStats.deletions,
@@ -741,12 +833,141 @@ export class GhExecutor {
741
833
  return null;
742
834
  }
743
835
  }
836
+ /**
837
+ * 发版输入:最近 tag + 该 tag 以来的提交(供 `github_task(prepare_release)` 使用)。
838
+ *
839
+ * 为什么要在 executor 里开这个口子,而不是让工具自己拼 git 命令:
840
+ * 命令形态必须集中(本文件顶部就是"git 白名单"纪律),否则每个新工具都会
841
+ * 自己发明一套 argv,`phase34` 的契约网也就管不到它们了。
842
+ *
843
+ * 安全性:两条命令的形态**全部固定**——`describe --tags --abbrev=0` 无参数注入;
844
+ * log 的修订范围段只拼 `git describe` 自己的输出,且先过 `/^[A-Za-z0-9._\/-]+$/`
845
+ * 白名单校验,杜绝把任意字符串当 revision 传进 git。
846
+ */
847
+ async getReleaseInputs(cwd, fromTagOverride) {
848
+ const empty = {
849
+ ok: false,
850
+ fromTag: null,
851
+ commits: [],
852
+ totalCommits: 0,
853
+ };
854
+ const runOpts = { cwd: cwd || undefined, timeout: 5_000, windowsHide: true };
855
+ let fromTag = null;
856
+ /*
857
+ * 显式指定的起始 tag 优先,且**必须真的作为 range 起点**。
858
+ *
859
+ * 为什么强调:只拿它当「版本号基数」而不改 range,会出现「按 v1.0.0 算版本、
860
+ * 但统计的是 v1.2.0 以来的提交」——数字与标签对不上,比直接报错更坏。
861
+ */
862
+ const override = typeof fromTagOverride === 'string' ? fromTagOverride.trim() : '';
863
+ if (override) {
864
+ /*
865
+ * 两条校验缺一不可:
866
+ * - 字符白名单挡住 `;` `$()` 这类命令注入;
867
+ * - **禁止以 `-` 开头**:它会被拼进 `git log <tag>..HEAD` 的第一个位置,
868
+ * git 会把 `-p..HEAD` 当成未知选项(实测报错)。虽然成不了真正的注入,
869
+ * 但「用户输入永远不许以 - 开头」是这条通道的基本纪律。
870
+ */
871
+ if (override.startsWith('-') || !/^[A-Za-z0-9._/-]+$/.test(override)) {
872
+ return { ...empty, error: `非法的 from_tag:${override}(只允许字母/数字/./_/-/,且不能以 - 开头)` };
873
+ }
874
+ fromTag = override;
875
+ }
876
+ else {
877
+ try {
878
+ const tagRes = await execFileAsync('git', ['describe', '--tags', '--abbrev=0'], runOpts);
879
+ const raw = String(tagRes.stdout || '').trim();
880
+ // 白名单:describe 的输出来自仓库自身,但仍要挡住 `..`、空白与 shell 元字符
881
+ if (raw && /^[A-Za-z0-9._/-]+$/.test(raw))
882
+ fromTag = raw;
883
+ }
884
+ catch {
885
+ // 没有任何 tag 是正常状态(新仓库),不是错误
886
+ fromTag = null;
887
+ }
888
+ }
889
+ try {
890
+ const range = fromTag ? `${fromTag}..HEAD` : 'HEAD';
891
+ const logRes = await execFileAsync('git',
892
+ // %b 带出提交正文:BREAKING CHANGE 常常只写在正文里,只看标题会漏判 major
893
+ ['log', range, '-n', '300', '--pretty=format:%x1e%h%x1f%s%x1f%an%x1f%ad%x1f%b'], runOpts);
894
+ const text = String(logRes.stdout || '');
895
+ const commits = text
896
+ .split('\x1e')
897
+ .map((chunk) => chunk.trim())
898
+ .filter(Boolean)
899
+ .map((chunk) => {
900
+ const parts = chunk.split('\x1f');
901
+ return {
902
+ sha: String(parts[0] || ''),
903
+ subject: String(parts[1] || ''),
904
+ author: String(parts[2] || ''),
905
+ date: String(parts[3] || ''),
906
+ // 正文只取前 500 字符:够判 BREAKING CHANGE,又不至于把整包撑大
907
+ body: String(parts.slice(4).join('\x1f') || '').slice(0, 500),
908
+ };
909
+ });
910
+ return { ok: true, fromTag, commits, totalCommits: commits.length };
911
+ }
912
+ catch (err) {
913
+ return { ...empty, fromTag, error: err?.message || '读取提交记录失败' };
914
+ }
915
+ }
916
+ /**
917
+ * 工作区干净度闸门(唯一实现)。
918
+ *
919
+ * 为什么抽成公共方法:会改变工作区分支/内容的入口有三条——界面切分支(switchGitBranch)、
920
+ * `github_pr(checkout)`、`github_issue(develop)`。此前只有第一条有脏检查,后两条直穿,
921
+ * 于是「强阻断」的成文承诺只对界面成立。安全不变量必须只有一处实现。
922
+ *
923
+ * 三态语义(fail-closed):
924
+ * - `null`(git 读不到)→ 拒绝。读不到状态≠干净,放行等于闸门失效。
925
+ * - `dirtyCount > 0` → 拒绝,并带前 5 条样例便于用户判断。
926
+ * - 其余 → 放行。
927
+ *
928
+ * 超时为什么单独给 20s:`getLocalGitStatus` 默认 3s 是给概览面板用的(读不到就不渲染那一行),
929
+ * 但这里是一次安全判决——大型仓库 / 网络盘 / OneDrive 同步目录下 `git status` 冷启动超 3s
930
+ * 很常见,用 3s 会把"慢"误判成"脏/不可用",直接把 checkout 挡死。
931
+ */
932
+ async ensureCleanWorkspace(cwd) {
933
+ const targetCwd = this.resolveWorkspaceCwd(cwd);
934
+ // 先区分"根本不是 Git 仓库"与"是仓库但读不到状态"——两者对用户的可操作性完全不同。
935
+ if (!hasGitDir(targetCwd)) {
936
+ return {
937
+ ok: false,
938
+ reason: 'unknown',
939
+ dirtyCount: 0,
940
+ samples: [],
941
+ error: `当前目录不是 Git 仓库(${targetCwd}),无法执行需要切换分支的操作。`,
942
+ };
943
+ }
944
+ const gitSt = await this.getLocalGitStatus(targetCwd, 20_000);
945
+ if (gitSt === null) {
946
+ return {
947
+ ok: false,
948
+ reason: 'unknown',
949
+ dirtyCount: 0,
950
+ samples: [],
951
+ error: '读取工作区 Git 状态失败(git 不可用、超时或权限不足)。为避免覆盖本地改动,已拒绝执行。',
952
+ };
953
+ }
954
+ if (gitSt.dirtyCount > 0) {
955
+ return {
956
+ ok: false,
957
+ reason: 'dirty',
958
+ dirtyCount: gitSt.dirtyCount,
959
+ samples: (gitSt.dirtyFiles || []).slice(0, 5).map((f) => `${f.status} ${f.path}`),
960
+ error: `工作区有 ${gitSt.dirtyCount} 个未提交的文件变更。为防止代码被覆盖,请先提交或保存后再执行。`,
961
+ };
962
+ }
963
+ return { ok: true, dirtyCount: 0, samples: [] };
964
+ }
744
965
  /**
745
966
  * 安全切换当前工作区的 Git 分支。
746
967
  * 三重安全防御纪律:
747
968
  * 1. 目标工作区必须存在有效的 .git 目录;
748
969
  * 2. 分支名称严格执行字符白名单校验,防命令行参数注入;
749
- * 3. 检查工作区干净度(dirtyCount),若有未提交或未保存的变更直接阻断,杜绝代码被破坏性覆盖。
970
+ * 3. 工作区干净度交给 ensureCleanWorkspace(唯一实现,与 checkout / develop 共用)。
750
971
  */
751
972
  async switchGitBranch(cwd, branchName) {
752
973
  const targetCwd = this.resolveWorkspaceCwd(cwd);
@@ -759,12 +980,9 @@ export class GhExecutor {
759
980
  return { ok: false, error: '非法分支名称,仅允许字母、数字、短横线、下划线与斜杠组合' };
760
981
  }
761
982
  // 防线二:工作区未提交变更阻断(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
- };
983
+ const guard = await this.ensureCleanWorkspace(targetCwd);
984
+ if (!guard.ok) {
985
+ return { ok: false, error: guard.error };
768
986
  }
769
987
  try {
770
988
  let args = ['checkout', cleanBranch];
@@ -890,8 +1108,8 @@ export class GhExecutor {
890
1108
  if (!num || isNaN(num) || num <= 0) {
891
1109
  return { ok: false, error: '缺少有效 PR 编号(必须为正整数)' };
892
1110
  }
893
- const cleanRepo = typeof repo === 'string' ? repo.trim() : '';
894
- if (cleanRepo && !/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(cleanRepo)) {
1111
+ const cleanRepo = this.normalizeRepoFullName(repo);
1112
+ if (cleanRepo === null) {
895
1113
  return { ok: false, error: '非法仓库全名参数(仅允许 owner/repo 格式)' };
896
1114
  }
897
1115
  const viewArgs = [
@@ -899,7 +1117,10 @@ export class GhExecutor {
899
1117
  'view',
900
1118
  String(num),
901
1119
  '--json',
902
- 'number,title,state,isDraft,url,createdAt,updatedAt,author,baseRefName,headRefName,body,reviewDecision,statusCheckRollup,comments,reviews,files,additions,deletions',
1120
+ // headRefOid/baseRefOid:分支会移动,SHA 才是可钉住的审查锚点(s7 §2.1)
1121
+ // changedFiles:`--json files` 在 100 条处静默截断,只有这个数字能判定「清单被截了」
1122
+ // headRepositoryOwner:`prDetail.headRepoOwner` 的取值来源(此前恒 undefined)
1123
+ 'number,title,state,isDraft,url,createdAt,updatedAt,author,baseRefName,headRefName,headRefOid,baseRefOid,changedFiles,headRepositoryOwner,body,reviewDecision,statusCheckRollup,comments,reviews,files,additions,deletions',
903
1124
  ];
904
1125
  if (cleanRepo)
905
1126
  viewArgs.push('-R', cleanRepo);
@@ -963,6 +1184,24 @@ export class GhExecutor {
963
1184
  }
964
1185
  const fullDiff = rDiff.ok ? (typeof rDiff.data === 'string' ? rDiff.data : rDiff.rawOutput || '') : '';
965
1186
  const fileDiffs = splitUnifiedDiff(fullDiff);
1187
+ const files = Array.isArray(raw.files)
1188
+ ? raw.files.map((f) => ({
1189
+ path: String(f.path || ''),
1190
+ additions: Number(f.additions) || 0,
1191
+ deletions: Number(f.deletions) || 0,
1192
+ changeType: String(f.changeType || 'MODIFIED'),
1193
+ }))
1194
+ : [];
1195
+ /*
1196
+ * 三个截断字段各自回答一个不同问题,缺一不可(s7 §2.5):
1197
+ * - changedFiles :仓库里这个 PR 到底改了多少个文件(权威总数)
1198
+ * - filesTruncated :清单被 gh 的 100 条上限裁掉了吗(cli/cli#12522 静默截断)
1199
+ * - diffIncomplete :清单里有、但切片里没有对应 diff 的文件(数据缺失,不是「没改动」)
1200
+ * 把三者混成一个布尔值,界面就只能说「可能不全」,用户无法判断该去做什么。
1201
+ */
1202
+ const changedFiles = Number(raw.changedFiles) || files.length;
1203
+ const filesTruncated = files.length < changedFiles;
1204
+ const diffIncomplete = files.some((f) => !fileDiffs[f.path]);
966
1205
  const prDetail = {
967
1206
  number: Number(raw.number) || num,
968
1207
  title: String(raw.title || ''),
@@ -977,20 +1216,18 @@ export class GhExecutor {
977
1216
  },
978
1217
  headRefName: String(raw.headRefName || ''),
979
1218
  baseRefName: String(raw.baseRefName || ''),
1219
+ headRefOid: raw.headRefOid ? String(raw.headRefOid) : undefined,
1220
+ baseRefOid: raw.baseRefOid ? String(raw.baseRefOid) : undefined,
980
1221
  headRepoOwner: raw.headRepositoryOwner?.login ? String(raw.headRepositoryOwner.login) : undefined,
981
1222
  body: String(raw.body || ''),
982
1223
  reviewDecision: raw.reviewDecision ? String(raw.reviewDecision) : undefined,
983
1224
  statusCheckRollup: raw.statusCheckRollup,
984
1225
  additions: Number(raw.additions) || 0,
985
1226
  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
- : [],
1227
+ files,
1228
+ changedFiles,
1229
+ ...(filesTruncated ? { filesTruncated: true } : {}),
1230
+ ...(diffIncomplete ? { diffIncomplete: true } : {}),
994
1231
  comments: Array.isArray(raw.comments)
995
1232
  ? raw.comments.map((c) => ({
996
1233
  id: String(c.id || ''),
@@ -1019,6 +1256,92 @@ export class GhExecutor {
1019
1256
  };
1020
1257
  return { ok: true, data: prDetail };
1021
1258
  }
1259
+ /**
1260
+ * 提交带行级评注的 PR review(REST 通道)。
1261
+ *
1262
+ * 为什么必须走 REST:`gh pr review` 只有 `--approve/--request-changes/--comment`,
1263
+ * 没有任何行级参数(`gh pr review --help` 里 line/side/path 零命中)。行级评注
1264
+ * 的唯一入口是 `POST /repos/{owner}/{repo}/pulls/{n}/reviews` 的 `comments[]`。
1265
+ *
1266
+ * 为什么一次 review 一个请求(而不是逐条 `POST /pulls/{n}/comments`):
1267
+ * 通知只发一轮,也避开 secondary rate limit 的「创建内容过快」判定。
1268
+ *
1269
+ * 422 降级:行级评注被拒(典型原因:该行不在 diff 的可评论侧,如纯删除行)
1270
+ * 时,把评注折叠进 review 正文重试一次。内容不能丢,但降级必须显式可见。
1271
+ */
1272
+ async createPrReview(cwd, repo, prNumber, input) {
1273
+ const targetCwd = this.resolveWorkspaceCwd(cwd);
1274
+ const num = Number(prNumber);
1275
+ if (!num || isNaN(num) || num <= 0) {
1276
+ return { ok: false, error: '缺少有效 PR 编号(必须为正整数)' };
1277
+ }
1278
+ // 仓库名优先用显式参数;缺省时从本地 git remote 推导(与 getPrDetail 同一套规则)
1279
+ const cleanRepo = this.normalizeRepoFullName(repo);
1280
+ if (cleanRepo === null) {
1281
+ return { ok: false, error: '非法仓库全名参数(仅允许 owner/repo 格式)' };
1282
+ }
1283
+ const fullName = cleanRepo || this.getRepoIdentityFromLocal(targetCwd)?.nameWithOwner || '';
1284
+ if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(fullName)) {
1285
+ return { ok: false, error: '无法确定仓库全名:请传 repo 参数,或在 Git 仓库目录下调用' };
1286
+ }
1287
+ const built = buildPrReviewPayload(input);
1288
+ if (!built.ok || !built.payload) {
1289
+ return { ok: false, error: `评注参数校验未通过:\n- ${(built.errors || ['未知错误']).join('\n- ')}` };
1290
+ }
1291
+ const payload = built.payload;
1292
+ const endpoint = `repos/${fullName}/pulls/${num}/reviews`;
1293
+ const post = (body) => this.run(['api', '--method', 'POST', endpoint, '--input', '-'], {
1294
+ cwd: targetCwd,
1295
+ timeoutMs: 20_000,
1296
+ stdin: JSON.stringify(body),
1297
+ });
1298
+ const first = await post(payload);
1299
+ if (first.ok) {
1300
+ const data = first.data;
1301
+ return {
1302
+ ok: true,
1303
+ data: {
1304
+ id: data?.id,
1305
+ htmlUrl: data?.html_url,
1306
+ state: data?.state,
1307
+ submittedAt: data?.submitted_at,
1308
+ // 逐条回传评论地址,供人复核「到底挂到了哪一行」
1309
+ comments: Array.isArray(data?.comments)
1310
+ ? data.comments.map((c) => ({
1311
+ path: String(c?.path || ''),
1312
+ line: typeof c?.line === 'number' ? c.line : undefined,
1313
+ htmlUrl: c?.html_url ? String(c.html_url) : undefined,
1314
+ }))
1315
+ : [],
1316
+ },
1317
+ };
1318
+ }
1319
+ // 只在「行级评注被拒」时降级;403/404/网络错误原样抛出(降级也没用)
1320
+ const hasComments = Array.isArray(payload.comments) && payload.comments.length > 0;
1321
+ if (hasComments && isLineCommentRejected(first.error || '')) {
1322
+ const folded = foldCommentsIntoBody(payload.comments, payload.body);
1323
+ const retry = await post({ ...payload, body: folded, comments: undefined });
1324
+ // 把 GitHub 的原始拒绝原因带进说明里:否则用户只看到「已折叠进正文」,
1325
+ // 却不知道是「行不在 diff 可评论侧」还是「path 根本不存在」——两者处置完全不同。
1326
+ const why = String(first.error || '').replace(/\s+/g, ' ').trim().slice(0, 200);
1327
+ if (retry.ok) {
1328
+ const data = retry.data;
1329
+ return {
1330
+ ok: true,
1331
+ degraded: true,
1332
+ degradedReason: `行级评注被 GitHub 拒绝(${why}),已折叠进 review 正文提交,内容未丢失。`,
1333
+ data: { id: data?.id, htmlUrl: data?.html_url, state: data?.state, submittedAt: data?.submitted_at, comments: [] },
1334
+ };
1335
+ }
1336
+ return {
1337
+ ok: false,
1338
+ degraded: true,
1339
+ degradedReason: `行级评注被拒(${why})后降级重试仍失败。`,
1340
+ error: retry.error || first.error,
1341
+ };
1342
+ }
1343
+ return { ok: false, error: first.error };
1344
+ }
1022
1345
  /**
1023
1346
  * 获取 Issue 完整详情(供 IssueDetailModal 在线查看消费)。
1024
1347
  *
@@ -1035,8 +1358,8 @@ export class GhExecutor {
1035
1358
  if (!num || isNaN(num) || num <= 0 || !Number.isInteger(num)) {
1036
1359
  return { ok: false, error: '缺少有效 Issue 编号(必须为正整数)' };
1037
1360
  }
1038
- const cleanRepo = typeof repo === 'string' ? repo.trim() : '';
1039
- if (cleanRepo && !/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(cleanRepo)) {
1361
+ const cleanRepo = this.normalizeRepoFullName(repo);
1362
+ if (cleanRepo === null) {
1040
1363
  return { ok: false, error: '非法仓库全名参数(仅允许 owner/repo 格式)' };
1041
1364
  }
1042
1365
  const buildArgs = (fields) => {
@@ -1166,6 +1489,249 @@ export class GhExecutor {
1166
1489
  };
1167
1490
  return { ok: true, data: detail };
1168
1491
  }
1492
+ /**
1493
+ * 详情类接口的仓库参数归一化(PR / Issue / Run 共用)。
1494
+ *
1495
+ * 返回 `''` = 未传(由本地 remote 决定);返回 `null` = 非法。
1496
+ *
1497
+ * 为什么要收编三处各自的校验:历史实现各写一份正则,只有 `setDefaultRepo` 额外
1498
+ * 拒绝 `-` 开头与 `..`,其余靠「恒以 `-R <value>` 成对传入」兜底(复核报告 C-13)。
1499
+ * 「成对传入所以安全」是当前调用形状的安全,不是接口的安全——多一处调用方就多
1500
+ * 一次赌。统一到这里后,`-` 开头/`..`/反斜杠在任何详情入口都进不来。
1501
+ */
1502
+ normalizeRepoFullName(repo) {
1503
+ const clean = typeof repo === 'string' ? repo.trim() : '';
1504
+ if (!clean)
1505
+ return '';
1506
+ if (clean.startsWith('-') || clean.includes('..') || clean.includes('\\'))
1507
+ return null;
1508
+ if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(clean))
1509
+ return null;
1510
+ return clean;
1511
+ }
1512
+ /**
1513
+ * 获取单次 Actions Run 的完整详情(run + jobs + steps)。
1514
+ *
1515
+ * 架构设计:
1516
+ * 1. **run_id 只认纯数字**。它会被当作 `gh run view` 的位置参数拼进 argv,
1517
+ * 不做白名单就等于把「用参数值注入 flag」这条已知缺陷(复核报告 C-01)
1518
+ * 复制到新通道——`--web`、`--log` 都能冒充编号。
1519
+ * 2. 单次往返拿全:`jobs` 里已经包含 steps 树,没有需要并行拉取的第二路。
1520
+ * 3. **日志不在这里拉**。实测单次失败日志 4–40 秒(本机代理下还直接连接重置),
1521
+ * 挂在详情上等于让弹窗首屏去赌网络;日志走 getRunLogExcerpts() 按需拉。
1522
+ */
1523
+ async getRunDetail(cwd, runId, repo) {
1524
+ const targetCwd = this.resolveWorkspaceCwd(cwd);
1525
+ const id = String(runId ?? '').trim();
1526
+ if (!/^\d+$/.test(id) || Number(id) <= 0) {
1527
+ return { ok: false, error: '缺少有效 Run ID(必须为正整数)' };
1528
+ }
1529
+ const cleanRepo = this.normalizeRepoFullName(repo);
1530
+ if (cleanRepo === null) {
1531
+ return { ok: false, error: '非法仓库全名参数(仅允许 owner/repo 格式)' };
1532
+ }
1533
+ const buildArgs = (fields) => {
1534
+ const args = ['run', 'view', id, '--json', fields];
1535
+ if (cleanRepo)
1536
+ args.push('-R', cleanRepo);
1537
+ return args;
1538
+ };
1539
+ /**
1540
+ * 输出阈值必须显式放宽。
1541
+ *
1542
+ * 为什么:矩阵构建的 jobs 数组动辄上百个 job、每个十几步,JSON 轻松过 100KB;
1543
+ * 沿用默认 24KB 会跳过 JSON.parse,data 恒为 undefined,于是弹窗渲染出
1544
+ * 「元数据全空 + 零 job」的假详情——比报错更糟(同 getIssueDetail 的教训)。
1545
+ */
1546
+ const execOpts = {
1547
+ cwd: targetCwd,
1548
+ timeoutMs: 20_000,
1549
+ maxOutputChars: 2_000_000,
1550
+ maxOutputBytes: 8 * 1024 * 1024,
1551
+ };
1552
+ let fieldsDegraded = false;
1553
+ let r = await this.run(buildArgs(RUN_DETAIL_FULL_FIELDS), execOpts);
1554
+ if (!r.ok && /Unknown JSON field/i.test(String(r.error || ''))) {
1555
+ fieldsDegraded = true;
1556
+ r = await this.run(buildArgs(RUN_DETAIL_BASE_FIELDS), execOpts);
1557
+ }
1558
+ if (!r.ok) {
1559
+ return { ok: false, error: r.error || `获取 Run #${id} 详情失败` };
1560
+ }
1561
+ // 截断 / 解析失败必须显式失败:渲染空 jobs 会被读成「这次运行没有 job」
1562
+ if (r.truncated || !r.data || typeof r.data !== 'object') {
1563
+ const link = cleanRepo ? `https://github.com/${cleanRepo}/actions/runs/${id}` : '仓库的 Actions 页面';
1564
+ return {
1565
+ ok: false,
1566
+ error: `Run #${id} 的 job 清单过长,已超出插件单次读取上限。请在浏览器中查看完整运行(${link})。`,
1567
+ };
1568
+ }
1569
+ const raw = r.data;
1570
+ const jobs = Array.isArray(raw.jobs)
1571
+ ? raw.jobs
1572
+ .filter((j) => j && (j.databaseId || j.name))
1573
+ .map((j) => {
1574
+ const steps = Array.isArray(j.steps)
1575
+ ? j.steps
1576
+ .filter((s) => s && typeof s === 'object')
1577
+ .map((s) => ({
1578
+ number: Number(s.number) || 0,
1579
+ name: String(s.name || ''),
1580
+ status: String(s.status || ''),
1581
+ ...(s.conclusion ? { conclusion: String(s.conclusion) } : {}),
1582
+ ...(s.startedAt ? { startedAt: String(s.startedAt) } : {}),
1583
+ ...(s.completedAt ? { completedAt: String(s.completedAt) } : {}),
1584
+ }))
1585
+ : [];
1586
+ return {
1587
+ databaseId: Number(j.databaseId) || 0,
1588
+ name: String(j.name || '未命名 job'),
1589
+ status: String(j.status || ''),
1590
+ ...(j.conclusion ? { conclusion: String(j.conclusion) } : {}),
1591
+ ...(j.startedAt ? { startedAt: String(j.startedAt) } : {}),
1592
+ ...(j.completedAt ? { completedAt: String(j.completedAt) } : {}),
1593
+ ...(j.url ? { url: String(j.url) } : {}),
1594
+ steps,
1595
+ };
1596
+ })
1597
+ : [];
1598
+ // 失败口径只算一次,UI 直接消费(避免前端再推导出第二套口径)
1599
+ const failedJobs = jobs.filter((j) => j.conclusion === 'failure').length;
1600
+ const failedSteps = jobs.reduce((sum, j) => sum + j.steps.filter((s) => s.conclusion === 'failure').length, 0);
1601
+ const detail = {
1602
+ runId: Number(raw.databaseId) || Number(id),
1603
+ ...(typeof raw.attempt === 'number' && raw.attempt > 0 ? { attempt: raw.attempt } : {}),
1604
+ name: String(raw.name || raw.workflowName || `Run ${id}`),
1605
+ ...(raw.displayTitle ? { displayTitle: String(raw.displayTitle) } : {}),
1606
+ status: String(raw.status || ''),
1607
+ ...(raw.conclusion ? { conclusion: String(raw.conclusion) } : {}),
1608
+ ...(raw.event ? { event: String(raw.event) } : {}),
1609
+ ...(raw.headBranch ? { headBranch: String(raw.headBranch) } : {}),
1610
+ ...(raw.headSha ? { headSha: String(raw.headSha) } : {}),
1611
+ url: String(raw.url || ''),
1612
+ createdAt: String(raw.createdAt || ''),
1613
+ ...(raw.startedAt ? { startedAt: String(raw.startedAt) } : {}),
1614
+ updatedAt: String(raw.updatedAt || ''),
1615
+ ...(raw.workflowName ? { workflowName: String(raw.workflowName) } : {}),
1616
+ jobs,
1617
+ failedJobs,
1618
+ failedSteps,
1619
+ ...(fieldsDegraded ? { fieldsDegraded: true } : {}),
1620
+ };
1621
+ return { ok: true, data: detail };
1622
+ }
1623
+ /**
1624
+ * 按需拉取失败日志并压成纯文本摘要(供 Run 详情弹窗消费)。
1625
+ *
1626
+ * 与 github_run(log_failed) 的关系:同一份 `--log-failed` 原始输出、同一套锚点
1627
+ * 萃取与尾部回退,只是排版不同(弹窗要的是 `<pre>` 纯文本,不是 Markdown)。
1628
+ *
1629
+ * 为什么逐 job 拉而不是整 run 一次拉:`gh run view --log-failed` 会把**整次运行**
1630
+ * 的日志包都下载下来再过滤,矩阵构建下等于为了一个失败 job 拖几十 MB;`-j <jobId>`
1631
+ * 是 gh 支持的定向形态(见 `gh run view --help`),失败 job 通常只有 1–3 个。
1632
+ *
1633
+ * 为什么单个 job 失败仍返回 ok:true:部分成功比整体失败有用——已拿到的块照常展示,
1634
+ * 失败的那个进 errors[] 并在界面上显式可重试。只有参数非法才是 ok:false。
1635
+ */
1636
+ async getRunLogExcerpts(cwd, runId, jobIds, repo,
1637
+ /**
1638
+ * 本批日志的总预算(毫秒,默认 `RUN_LOG_TOTAL_BUDGET_MS`)。
1639
+ *
1640
+ * 为什么需要:单 job 超时 60s × 最多 3 个串行 = 最坏 180s。这个调用挂在 HTTP
1641
+ * 路由上,而宿主 WebServer 是全 GUI 共享的——一次点击占住三分钟不可接受。
1642
+ * 超预算的 job 进 `errors[]`(显式、可单独重试),而不是让连接一直挂着。
1643
+ * 允许外部传入只为可测:传 0 能验证「超预算」分支而不必真的等待。
1644
+ */
1645
+ opts = {}) {
1646
+ const targetCwd = this.resolveWorkspaceCwd(cwd);
1647
+ const id = String(runId ?? '').trim();
1648
+ if (!/^\d+$/.test(id) || Number(id) <= 0) {
1649
+ return { ok: false, error: '缺少有效 Run ID(必须为正整数)' };
1650
+ }
1651
+ const cleanRepo = this.normalizeRepoFullName(repo);
1652
+ if (cleanRepo === null) {
1653
+ return { ok: false, error: '非法仓库全名参数(仅允许 owner/repo 格式)' };
1654
+ }
1655
+ const wanted = Array.from(new Set((Array.isArray(jobIds) ? jobIds : [])
1656
+ .map((v) => String(v ?? '').trim())
1657
+ .filter((v) => /^\d+$/.test(v) && Number(v) > 0)));
1658
+ if (wanted.length === 0) {
1659
+ return { ok: false, error: '缺少有效 job_id(必须为正整数,最多 3 个)' };
1660
+ }
1661
+ // 超上限必须显式拒绝:静默 slice 会让调用方以为 5 个 job 的日志都拉了,
1662
+ // 与仓库自己立的「截断必须可见」纪律(filesTruncated / totalUnknown)相悖。
1663
+ if (wanted.length > RUN_LOG_MAX_JOBS) {
1664
+ return {
1665
+ ok: false,
1666
+ error: `一次最多拉取 ${RUN_LOG_MAX_JOBS} 个 job 的失败日志(本次请求 ${wanted.length} 个)。请分批重试,或直接在 GitHub 查看完整日志。`,
1667
+ };
1668
+ }
1669
+ // 名字从详情树里取(job id → 名称)。取不到不影响拉日志,但**必须显式标注**:
1670
+ // 名字退化成 `job #111` 是可见的降级,不能让它悄悄发生(本仓的降级纪律)。
1671
+ const detail = await this.getRunDetail(targetCwd, id, cleanRepo || undefined).catch(() => null);
1672
+ const nameById = new Map();
1673
+ for (const job of detail && detail.ok && detail.data ? detail.data.jobs : []) {
1674
+ if (job.databaseId > 0)
1675
+ nameById.set(job.databaseId, job.name);
1676
+ }
1677
+ const namesUnavailable = wanted.some((jobId) => !nameById.has(Number(jobId)));
1678
+ const jobs = [];
1679
+ const errors = [];
1680
+ // 总预算:3 个 job 各 60s 串行会把一条 HTTP 请求拖到 180s(见方法注释)
1681
+ const budget = Number.isFinite(opts.totalBudgetMs)
1682
+ ? Math.max(0, Math.floor(opts.totalBudgetMs))
1683
+ : RUN_LOG_TOTAL_BUDGET_MS;
1684
+ const deadline = Date.now() + budget;
1685
+ // 串行而非并发:每次都是一次整包下载,并发只会同时挤爆代理与 gh 缓存目录
1686
+ for (const jobId of wanted) {
1687
+ const jobName = nameById.get(Number(jobId)) || `job #${jobId}`;
1688
+ // 定向日志通道:`-j <jobId> --log-failed` 只下这个 job 的失败步骤日志
1689
+ const logArgs = ['run', 'view', id, '-j', jobId, '--log-failed'];
1690
+ if (cleanRepo)
1691
+ logArgs.push('-R', cleanRepo);
1692
+ // 预算在**每次**调用前重算:前一个 job 慢,后面的就该直接判超预算而不是再赌一次
1693
+ const remaining = deadline - Date.now();
1694
+ if (remaining <= 0) {
1695
+ errors.push({
1696
+ jobName,
1697
+ error: `超出本批日志总预算(${Math.round(budget / 1000)}s),该 job 未拉取。请单独重试。`,
1698
+ });
1699
+ continue;
1700
+ }
1701
+ const res = await this.run(logArgs, {
1702
+ cwd: targetCwd,
1703
+ // 单个 job 最多 60s,但绝不越过本批总预算
1704
+ timeoutMs: Math.min(60_000, remaining),
1705
+ rawText: true,
1706
+ maxOutputChars: 2_000_000,
1707
+ maxOutputBytes: 8 * 1024 * 1024,
1708
+ });
1709
+ if (!res.ok) {
1710
+ const friendly = interpretRunLogError(String(res.error || ''), id);
1711
+ if (friendly) {
1712
+ // 确定性结论(该 job 没有失败步骤 / 缓存目录无权限)不是故障,进正文而非 errors
1713
+ jobs.push({ jobId: Number(jobId), jobName, excerpt: friendly, truncated: false });
1714
+ }
1715
+ else {
1716
+ errors.push({ jobName, error: String(res.error || '日志拉取失败').trim().slice(0, 400) });
1717
+ }
1718
+ continue;
1719
+ }
1720
+ const built = buildPlainFailureExcerpt(res.rawOutput || '', {
1721
+ budget: RUN_LOG_BUDGET_CHARS,
1722
+ truncated: Boolean(res.truncated),
1723
+ });
1724
+ jobs.push({ jobId: Number(jobId), jobName, excerpt: built.text, truncated: built.truncated });
1725
+ }
1726
+ const data = {
1727
+ runId: Number(id),
1728
+ jobs,
1729
+ ...(errors.length > 0 ? { errors } : {}),
1730
+ ...(namesUnavailable ? { namesUnavailable: true } : {}),
1731
+ fetchedAt: new Date().toISOString(),
1732
+ };
1733
+ return { ok: true, data };
1734
+ }
1169
1735
  /** 生成当前操作系统平台的推荐安装引导与命令 */
1170
1736
  getPlatformInstallGuide() {
1171
1737
  return getPlatformInstallGuide();