dsh-github-flow 0.5.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 (103) hide show
  1. package/README.en.md +112 -17
  2. package/README.md +115 -17
  3. package/docs/UPGRADE-NOTES.md +12 -11
  4. package/docs/architecture.md +81 -22
  5. package/docs/images/1.png +0 -0
  6. package/lib/api/route-helpers.d.ts +19 -0
  7. package/lib/api/route-helpers.js +54 -0
  8. package/lib/api/routes-actions.js +214 -9
  9. package/lib/api/routes-lifecycle.js +8 -1
  10. package/lib/api/routes-overview.d.ts +26 -0
  11. package/lib/api/routes-overview.js +396 -36
  12. package/lib/api/routes-proxy.js +8 -1
  13. package/lib/api/routes.d.ts +11 -1
  14. package/lib/api/routes.js +15 -2
  15. package/lib/cache.d.ts +56 -4
  16. package/lib/cache.js +77 -7
  17. package/lib/client.d.ts +8 -3
  18. package/lib/client.js +5542 -1391
  19. package/lib/destructive.d.ts +59 -0
  20. package/lib/destructive.js +285 -0
  21. package/lib/executor/args-guard.d.ts +17 -0
  22. package/lib/executor/args-guard.js +23 -0
  23. package/lib/executor/error-classifier.d.ts +55 -0
  24. package/lib/executor/error-classifier.js +199 -0
  25. package/lib/executor/failure-anchors.d.ts +52 -0
  26. package/lib/executor/failure-anchors.js +116 -0
  27. package/lib/executor/git-parser.d.ts +124 -0
  28. package/lib/executor/git-parser.js +397 -0
  29. package/lib/executor/pr-review.d.ts +95 -0
  30. package/lib/executor/pr-review.js +175 -0
  31. package/lib/executor/run-log.d.ts +36 -0
  32. package/lib/executor/run-log.js +78 -0
  33. package/lib/executor.d.ts +277 -6
  34. package/lib/executor.js +1265 -94
  35. package/lib/github-service.d.ts +1 -1
  36. package/lib/main.d.ts +92 -18
  37. package/lib/main.js +186 -95
  38. package/lib/renderers/common.d.ts +0 -3
  39. package/lib/renderers/common.js +8 -44
  40. package/lib/renderers/markdown.d.ts +1 -0
  41. package/lib/renderers/markdown.js +1 -0
  42. package/lib/renderers/pr.js +26 -1
  43. package/lib/renderers/task.d.ts +9 -0
  44. package/lib/renderers/task.js +120 -0
  45. package/lib/review-rules.d.ts +60 -0
  46. package/lib/review-rules.js +204 -0
  47. package/lib/shared/state-badges.d.ts +42 -0
  48. package/lib/shared/state-badges.js +102 -0
  49. package/lib/task-primitives.d.ts +109 -0
  50. package/lib/task-primitives.js +194 -0
  51. package/lib/tool-tiers.d.ts +57 -0
  52. package/lib/tool-tiers.js +168 -0
  53. package/lib/tools/agent-task.js +4 -4
  54. package/lib/tools/api.js +18 -6
  55. package/lib/tools/cache.js +42 -40
  56. package/lib/tools/codespace.js +4 -12
  57. package/lib/tools/discussion.js +4 -4
  58. package/lib/tools/gist.d.ts +1 -39
  59. package/lib/tools/gist.js +4 -14
  60. package/lib/tools/help.js +72 -18
  61. package/lib/tools/image-utils.d.ts +27 -0
  62. package/lib/tools/image-utils.js +73 -3
  63. package/lib/tools/issue.js +16 -28
  64. package/lib/tools/label.js +4 -15
  65. package/lib/tools/pr.d.ts +47 -0
  66. package/lib/tools/pr.js +170 -33
  67. package/lib/tools/project.js +4 -13
  68. package/lib/tools/release.js +11 -22
  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 +219 -0
  76. package/lib/tools/task.js +457 -0
  77. package/lib/types.d.ts +514 -1
  78. package/lib/ui/help-modal.d.ts +6 -0
  79. package/lib/ui/help-modal.js +124 -16
  80. package/lib/ui/issue-modal.d.ts +14 -0
  81. package/lib/ui/issue-modal.js +370 -0
  82. package/lib/ui/markdown-lite.d.ts +66 -0
  83. package/lib/ui/markdown-lite.js +312 -0
  84. package/lib/ui/network-banner.d.ts +12 -0
  85. package/lib/ui/network-banner.js +35 -0
  86. package/lib/ui/pr-modal.d.ts +17 -0
  87. package/lib/ui/pr-modal.js +555 -0
  88. package/lib/ui/repo-badges.d.ts +43 -0
  89. package/lib/ui/repo-badges.js +82 -0
  90. package/lib/ui/repo-selector.d.ts +14 -0
  91. package/lib/ui/repo-selector.js +112 -0
  92. package/lib/ui/run-modal.d.ts +26 -0
  93. package/lib/ui/run-modal.js +486 -0
  94. package/lib/ui/storage.d.ts +47 -0
  95. package/lib/ui/storage.js +139 -0
  96. package/lib/ui/styles.d.ts +1 -1
  97. package/lib/ui/styles.js +1330 -44
  98. package/lib/ui/sync-settings-modal.js +1 -1
  99. package/lib/ui/utils.js +13 -1
  100. package/lib/ui/workspace-panel.js +226 -29
  101. package/lib/version.d.ts +1 -1
  102. package/lib/version.js +1 -1
  103. package/package.json +9 -2
package/lib/executor.js CHANGED
@@ -1,16 +1,122 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
+ import crypto from 'node:crypto';
3
4
  import fs from 'node:fs';
4
5
  import os from 'node:os';
5
6
  import path from 'node:path';
6
7
  import { findMostRecentWorkspacePath, findWorkspacePathBySession, hasGitDir } from './workspace.js';
8
+ // 图片 URL 提取复用 Agent 工具链的同一份实现:弹窗只展示计数与外链,
9
+ // 但「哪些 URL 算图片」必须与 github_issue(view_images) 完全同源,否则
10
+ // 用户会看到「附件 2 张」却下载不到对应图片。
11
+ import { extractImageUrls } from './tools/image-utils.js';
7
12
  // 子模块纯函数解耦导入并对外部 Re-export 保证 100% 向后兼容
8
13
  import { parseGhVersion, compareGhVersions } from './executor/version-utils.js';
9
14
  import { spawnWithStdin, safeSlice, cutByUtf8Bytes, truncateOutput } from './executor/process-utils.js';
10
- import { parseGitStatusOutput, parseGitLogLines, parseGitBranchLines, parseUncommittedDiff, parseGitStatText } from './executor/git-parser.js';
15
+ import { buildPrReviewPayload, foldCommentsIntoBody, isLineCommentRejected } from './executor/pr-review.js';
16
+ import { parseGitStatusOutput, parseGitLogLines, parseGitBranchLines, parseUncommittedDiff, parseGitStatText, parseRemoteUrls, parseGitConfig, resolveRemoteTarget, parseGithubUrl, parseRemoteHost, classifyRepoLayout, pickGithubRemote, splitUnifiedDiff, } from './executor/git-parser.js';
11
17
  import { getPlatformInstallGuide, getGhUpgradeHint } from './executor/platform-guide.js';
12
- export { parseGhVersion, compareGhVersions, spawnWithStdin, safeSlice, cutByUtf8Bytes, truncateOutput, parseGitStatusOutput, parseGitLogLines, parseGitBranchLines, parseUncommittedDiff, parseGitStatText, getPlatformInstallGuide, getGhUpgradeHint, };
18
+ import { buildPlainFailureExcerpt, interpretRunLogError } from './executor/run-log.js';
19
+ import { classifyGhError, describeDeterministicError, isRetryableError, permissionRemedy, isPermissionError } from './executor/error-classifier.js';
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, };
13
21
  const execFileAsync = promisify(execFile);
22
+ /**
23
+ * `gh repo view` 的字段集。
24
+ *
25
+ * 为什么必须集中一处:同一份字段列表此前在 getRepoMetadata 里重复了两遍,
26
+ * 补字段时漏改一处就会出现「在线路径有 isFork、兜底路径没有」的不一致。
27
+ *
28
+ * 字段取舍:
29
+ * - parent 只返回骨架(id/name/owner.login),不含 stargazerCount
30
+ * ——`--json` 不支持嵌套子字段(实测 `Unknown JSON field: "parent.stargazerCount"`)
31
+ * - viewerPermission 是只读仓库的写操作前置拦截依据
32
+ */
33
+ const REPO_VIEW_FIELDS = 'nameWithOwner,name,owner,defaultBranchRef,description,isPrivate,url,stargazerCount,forkCount,isFork,parent,viewerPermission';
34
+ /**
35
+ * `gh issue view --json` 的完整字段集(gh 2.96.0 实测的合法全集子集)。
36
+ *
37
+ * 为什么集中一处:同一份字段列表要被「常规调用」与「降级重试」两处消费,
38
+ * 散落成字面量必然漂移。字段取舍全部基于实测响应形态:
39
+ * - `closedByPullRequestsReferences` 节点只有 id/number/repository/url,
40
+ * 不含 title/state,UI 因此只渲染编号链接;
41
+ * - `milestone` 形态为 {number,title,description,dueOn},没有 state;
42
+ * - `subIssuesSummary` 为 {completed,percentCompleted,total}。
43
+ */
44
+ const ISSUE_DETAIL_FULL_FIELDS = 'number,title,state,stateReason,author,createdAt,updatedAt,closedAt,url,body,labels,assignees,milestone,isPinned,issueType,comments,closedByPullRequestsReferences,subIssuesSummary';
45
+ /**
46
+ * Issue 详情的降级字段集。
47
+ *
48
+ * 为什么需要:老版本 gh 遇到未知 JSON 字段会**整体失败**(实测
49
+ * `gh issue view --json bogusField` → `Unknown JSON field: "bogusField"`)。
50
+ * 这里只保留长期稳定、几乎不可能被移除的字段,保证低版本宿主仍能看到
51
+ * 正文与评论时间线——缺失的里程碑/关联 PR 只是少几个装饰位,不致命。
52
+ */
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;
83
+ /**
84
+ * 把 `gh repo view --json` 的原始结果映射为 RepoMetadata。
85
+ *
86
+ * 抽出的理由:同一段映射原先在 getRepoMetadata 里写了两遍(上下文探测 + 精确探测),
87
+ * 逐字符相同。补 3 个字段要改两处,必然漂移。
88
+ *
89
+ * @param fallbackNameWithOwner d.nameWithOwner 缺失时的兜底(离线解析结果)
90
+ */
91
+ function mapRepoMetadata(d, fallbackNameWithOwner) {
92
+ const nameWithOwner = d?.nameWithOwner || fallbackNameWithOwner || '';
93
+ const meta = {
94
+ nameWithOwner,
95
+ name: d?.name || '',
96
+ owner: d?.owner?.login || '',
97
+ defaultBranch: d?.defaultBranchRef?.name || 'main',
98
+ description: d?.description || '',
99
+ isPrivate: Boolean(d?.isPrivate),
100
+ url: d?.url || `https://github.com/${nameWithOwner}`,
101
+ stargazerCount: typeof d?.stargazerCount === 'number' ? d.stargazerCount : 0,
102
+ forkCount: typeof d?.forkCount === 'number' ? d.forkCount : 0,
103
+ };
104
+ if (typeof d?.isFork === 'boolean')
105
+ meta.isFork = d.isFork;
106
+ // parent 的 nameWithOwner 需自行拼接:gh 只给 {name, owner:{login}}
107
+ const parentOwner = d?.parent?.owner?.login;
108
+ const parentName = d?.parent?.name;
109
+ if (typeof d?.isFork === 'boolean' && d.isFork && typeof parentOwner === 'string' && typeof parentName === 'string') {
110
+ const parentFull = `${parentOwner}/${parentName}`;
111
+ meta.parent = { nameWithOwner: parentFull, url: `https://github.com/${parentFull}` };
112
+ }
113
+ // 只接受已知档位,未知值一律不带——避免把陌生的 gh 新档位当成可写权限
114
+ const perm = typeof d?.viewerPermission === 'string' ? d.viewerPermission.toUpperCase() : '';
115
+ if (perm === 'ADMIN' || perm === 'MAINTAIN' || perm === 'WRITE' || perm === 'TRIAGE' || perm === 'READ') {
116
+ meta.viewerPermission = perm;
117
+ }
118
+ return meta;
119
+ }
14
120
  /** 认证状态缓存时长。用户手动 `gh auth login/logout` 后最多 30 秒内面板才会反映,属可接受代价 */
15
121
  const AUTH_CACHE_TTL_MS = 30_000;
16
122
  /** gh 版本缓存时长。与 authCache 同哲学:gh 升级是分钟级低频事件,30 秒陈旧窗口无实际影响 */
@@ -21,6 +127,14 @@ export class GhExecutor {
21
127
  maxOutputChars;
22
128
  /** UTF-8 字节口径的硬上限;未配置时按 maxOutputChars × 4 推导(覆盖 CJK 3 字节与 emoji 4 字节) */
23
129
  maxOutputBytes;
130
+ /**
131
+ * 累计 gh 子进程调用次数(只增不清零,`run()` 每次 +1)。
132
+ *
133
+ * 为什么需要:任务原语(github_task)要如实汇报 `cost.ghCalls`,
134
+ * 而「原语是否真的比手动串步骤省钱」是这个能力存在的唯一判据。
135
+ * 消费方用「调用前快照 → 调用后求差」的方式取区间计数。
136
+ */
137
+ ghCalls = 0;
24
138
  /**
25
139
  * 列表类查询的默认条数上限。
26
140
  * 为什么挂在 executor 而不是给每个工具工厂加 config 参数:executor 是全部
@@ -105,11 +219,51 @@ export class GhExecutor {
105
219
  return recent;
106
220
  return explicitCwd || process.cwd();
107
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
+ }
108
254
  /**
109
255
  * 安全执行 gh 命令
110
256
  * 采用 execFile 参数数组隔离,彻底杜绝 Shell 字符串拼接注入漏洞
111
257
  */
112
258
  async run(args, options = {}) {
259
+ /*
260
+ * gh 子进程调用计数(只增不清零)。
261
+ *
262
+ * 为什么放在 executor 而不是各工具里手数:任务原语(github_task)必须能如实
263
+ * 汇报 `cost.ghCalls`,而「原语是否真的省钱」是它存在的唯一判据。
264
+ * 手写计数必然与实际调用漂移——真实调用点只有这里一个。
265
+ */
266
+ this.ghCalls++;
113
267
  const cwd = this.resolveWorkspaceCwd(options.cwd, options.sessionId);
114
268
  const timeout = options.timeoutMs || this.defaultTimeoutMs;
115
269
  // 二进制模式(如 gh api 下载图片字节流):stdout 保持 Buffer,绕过 UTF-8
@@ -162,7 +316,8 @@ export class GhExecutor {
162
316
  const errBuf = Buffer.isBuffer(stderr) ? stderr : (stderr ? Buffer.from(String(stderr)) : null);
163
317
  if (!buf.length) {
164
318
  const errMsg = errBuf ? errBuf.toString('utf8').trim() : '';
165
- return { ok: false, error: errMsg || '二进制输出为空' };
319
+ const msg = errMsg || '二进制输出为空';
320
+ return { ok: false, error: msg, errorKind: classifyGhError(msg) };
166
321
  }
167
322
  return { ok: true, data: buf, binaryBytes: buf.length, truncated: false };
168
323
  }
@@ -172,7 +327,15 @@ export class GhExecutor {
172
327
  if (!rawOutput && errText) {
173
328
  rawOutput = errText.trim();
174
329
  }
175
- const outcome = truncateOutput(rawOutput, this.maxOutputChars, 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);
176
339
  rawOutput = outcome.text;
177
340
  const truncated = outcome.truncated;
178
341
  // 如果未被截断且命令带有 --json 或包含 api,尝试解析为 JSON
@@ -214,10 +377,24 @@ export class GhExecutor {
214
377
  rawOutput = truncateOutput(outText.trim(), this.maxOutputChars, this.maxOutputBytes).text;
215
378
  }
216
379
  }
380
+ // 权限不足时的处置指引。
381
+ //
382
+ // 为什么在 executor 层拼进 error,而不是让 97 个工具调用点各自 `throw new Error(res.error + hint)`:
383
+ // 全仓有 97 处 `throw new Error(res.error)`,逐一改必然遗漏;而模型只能看到 error
384
+ // 字符串,不拼进去等于永远看不到指引。在唯一的事实源处拼一次,所有调用点自动获益。
385
+ //
386
+ // permissionRemedy 字段仍然独立保留:程序化消费方(未来的 UI、诊断面板)
387
+ // 可以只取指引而不用从 error 里再解析一遍。
388
+ const remedy = permissionRemedy(friendlyError, args.join(' '));
389
+ const finalError = remedy ? `${friendlyError}\n\n${remedy}` : friendlyError;
217
390
  return {
218
391
  ok: false,
219
- error: friendlyError,
392
+ error: finalError,
220
393
  rawOutput,
394
+ // 失败语义分类:上层(概览路由 / UI 重试器)据此决定是重试、回退快照
395
+ // 还是直接把「仓库关了 Issues」降级成空列表。分类只有这一处事实源。
396
+ errorKind: classifyGhError(friendlyError),
397
+ ...(remedy ? { permissionRemedy: remedy } : {}),
221
398
  };
222
399
  }
223
400
  }
@@ -335,18 +512,20 @@ export class GhExecutor {
335
512
  * 零网络:status 基于 remote-tracking ref(上次 fetch 的快照),毫秒级本地读取,
336
513
  * 符合 global-overview 端点「工作区探测不发网络」的性能红线。
337
514
  */
338
- async getLocalGitStatus(cwd) {
515
+ async getLocalGitStatus(cwd, timeoutMs = 3_000) {
339
516
  try {
340
- // 纯本地毫秒级;3s 超时兜底异常文件系统
517
+ // 默认 3s:概览面板只是"读不到就不渲染这一行"的兜底语义。
518
+ // 但安全闸门(ensureCleanWorkspace)必须显式传更长超时——大型仓库 / 网络盘上
519
+ // `git status` 冷启动超 3s 很常见,用 3s 会把"读不到"误判成"有风险"。
341
520
  const { stdout } = await execFileAsync('git', ['status', '-sb', '--porcelain'], {
342
521
  cwd: cwd || undefined,
343
- timeout: 3_000,
522
+ timeout: timeoutMs,
344
523
  windowsHide: true,
345
524
  });
346
525
  return parseGitStatusOutput(String(stdout), 2000);
347
526
  }
348
527
  catch {
349
- // 非 Git 目录 / git 未安装 / 超时:静默降级为 null(调用方不渲染该行)
528
+ // 非 Git 目录 / git 未安装 / 超时:静默降级为 null(调用方自行决定语义)
350
529
  return null;
351
530
  }
352
531
  }
@@ -396,7 +575,9 @@ export class GhExecutor {
396
575
  }
397
576
  try {
398
577
  // 1. 先用 porcelain status 获取该文件的状态码
399
- let fileStatus = 'M';
578
+ // 默认值必须为空:旧实现默认 'M',导致工作区干净的文件被报成「已修改」
579
+ // (UI 表现为"文件被改了,但 diff 是空的")。状态只从 porcelain 结果推导。
580
+ let fileStatus = '';
400
581
  try {
401
582
  const { stdout: statusOut } = await execFileAsync('git', ['status', '--porcelain', '--', cleanRel], {
402
583
  cwd: targetCwd || undefined,
@@ -492,7 +673,7 @@ export class GhExecutor {
492
673
  return {
493
674
  ok: true,
494
675
  filePath: cleanRel,
495
- status: fileStatus,
676
+ status: fileStatus || 'M',
496
677
  isBinary: true,
497
678
  diff: 'Binary files differ',
498
679
  additions: 0,
@@ -509,7 +690,8 @@ export class GhExecutor {
509
690
  return {
510
691
  ok: true,
511
692
  filePath: cleanRel,
512
- status: fileStatus,
693
+ // porcelain 没给状态且没有 diff → 该文件其实是干净的,显式标 'clean' 而不是伪造成 'M'
694
+ status: fileStatus || (finalDiff ? 'M' : 'clean'),
513
695
  diff: finalDiff || '没有检测到文本差异',
514
696
  additions: diffStats.additions,
515
697
  deletions: diffStats.deletions,
@@ -651,12 +833,141 @@ export class GhExecutor {
651
833
  return null;
652
834
  }
653
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
+ }
654
965
  /**
655
966
  * 安全切换当前工作区的 Git 分支。
656
967
  * 三重安全防御纪律:
657
968
  * 1. 目标工作区必须存在有效的 .git 目录;
658
969
  * 2. 分支名称严格执行字符白名单校验,防命令行参数注入;
659
- * 3. 检查工作区干净度(dirtyCount),若有未提交或未保存的变更直接阻断,杜绝代码被破坏性覆盖。
970
+ * 3. 工作区干净度交给 ensureCleanWorkspace(唯一实现,与 checkout / develop 共用)。
660
971
  */
661
972
  async switchGitBranch(cwd, branchName) {
662
973
  const targetCwd = this.resolveWorkspaceCwd(cwd);
@@ -669,12 +980,9 @@ export class GhExecutor {
669
980
  return { ok: false, error: '非法分支名称,仅允许字母、数字、短横线、下划线与斜杠组合' };
670
981
  }
671
982
  // 防线二:工作区未提交变更阻断(Dirty File Guard)
672
- const gitSt = await this.getLocalGitStatus(targetCwd);
673
- if (gitSt && gitSt.dirtyCount > 0) {
674
- return {
675
- ok: false,
676
- error: `工作区有 ${gitSt.dirtyCount} 个未提交的文件变更。为防止代码被覆盖,请先提交或保存后再切换分支。`,
677
- };
983
+ const guard = await this.ensureCleanWorkspace(targetCwd);
984
+ if (!guard.ok) {
985
+ return { ok: false, error: guard.error };
678
986
  }
679
987
  try {
680
988
  let args = ['checkout', cleanBranch];
@@ -699,6 +1007,731 @@ export class GhExecutor {
699
1007
  return { ok: false, error: msg };
700
1008
  }
701
1009
  }
1010
+ /**
1011
+ * 推导当前工作区可供用户切换的全部关联 GitHub 仓库列表。
1012
+ *
1013
+ * 数据来源(纯本地+拓扑合成,零新增网络开销):
1014
+ * 1. 扫描本地 .git/config 的所有 GitHub remote(如 origin, upstream 等);
1015
+ * 2. 若当前仓库为 Fork 且存在 parent(上游),合并上游仓库(支持本地未配 upstream 的情形);
1016
+ * 3. 标识当前基准 (isCurrent) 及角色 (fork, upstream, origin, parent)。
1017
+ */
1018
+ getAssociatedRepos(cwd, currentRepo, currentUser) {
1019
+ const items = [];
1020
+ const seen = new Set();
1021
+ const cfg = this.readParsedGitConfig(cwd);
1022
+ if (cfg && Array.isArray(cfg.remotes)) {
1023
+ for (const r of cfg.remotes) {
1024
+ const gh = parseGithubUrl(r.url);
1025
+ if (!gh)
1026
+ continue;
1027
+ const full = `${gh.owner}/${gh.repo}`;
1028
+ const fullKey = full.toLowerCase();
1029
+ if (seen.has(fullKey))
1030
+ continue;
1031
+ seen.add(fullKey);
1032
+ const isCurrent = Boolean(currentRepo && currentRepo.nameWithOwner && currentRepo.nameWithOwner.toLowerCase() === fullKey);
1033
+ let role = 'remote';
1034
+ if (r.name === 'upstream') {
1035
+ role = 'upstream';
1036
+ }
1037
+ else if (currentUser && gh.owner.toLowerCase() === currentUser.toLowerCase()) {
1038
+ role = 'fork';
1039
+ }
1040
+ else if (r.name === 'origin') {
1041
+ role = 'origin';
1042
+ }
1043
+ items.push({
1044
+ nameWithOwner: full,
1045
+ remoteName: r.name,
1046
+ url: `https://github.com/${full}`,
1047
+ isCurrent,
1048
+ role,
1049
+ });
1050
+ }
1051
+ }
1052
+ // 若当前是在线识别出的 Fork 仓库且存在 parent,但本地尚未配置 upstream remote
1053
+ if (currentRepo?.isFork && currentRepo.parent?.nameWithOwner) {
1054
+ const parentFull = currentRepo.parent.nameWithOwner;
1055
+ const parentKey = parentFull.toLowerCase();
1056
+ if (!seen.has(parentKey)) {
1057
+ seen.add(parentKey);
1058
+ const isCurrent = Boolean(currentRepo.nameWithOwner && currentRepo.nameWithOwner.toLowerCase() === parentKey);
1059
+ items.push({
1060
+ nameWithOwner: parentFull,
1061
+ url: currentRepo.parent.url || `https://github.com/${parentFull}`,
1062
+ isCurrent,
1063
+ role: 'parent',
1064
+ });
1065
+ }
1066
+ }
1067
+ return items;
1068
+ }
1069
+ /**
1070
+ * 设置当前工作区的默认基准 GitHub 仓库(调用官方 `gh repo set-default` 并在本地 git config 持久化)。
1071
+ *
1072
+ * 为什么调用 gh repo set-default:
1073
+ * 1. 它会在 .git/config 写入 `remote.<name>.gh-resolved = base`,这是官方的持久化事实源;
1074
+ * 2. 执行后,不仅本插件的 Web 驾驶舱,外部系统终端中运行的 gh pr, gh issue, gh run 也将 100% 对齐;
1075
+ * 3. 严格做参数格式校验,仅允许合法的 owner/repo 或合法 remote 别名,杜绝命令注入。
1076
+ */
1077
+ async setDefaultRepo(cwd, targetRepoOrRemote) {
1078
+ const targetCwd = this.resolveWorkspaceCwd(cwd);
1079
+ if (!hasGitDir(targetCwd)) {
1080
+ return { ok: false, error: '当前工作区不是有效的 Git 仓库' };
1081
+ }
1082
+ const clean = (targetRepoOrRemote || '').trim();
1083
+ // 安全白名单校验:只允许字母、数字、点号、短横线、下划线及至多一个斜杠(owner/repo 或 remoteName)
1084
+ if (!clean || clean.startsWith('-') || clean.includes('..') || clean.includes('\\') || !/^[a-zA-Z0-9_.-]+(\/[a-zA-Z0-9_.-]+)?$/.test(clean)) {
1085
+ return { ok: false, error: '非法目标仓库参数,仅允许 owner/repo 或有效 remote 别名' };
1086
+ }
1087
+ const r = await this.run(['repo', 'set-default', clean], {
1088
+ cwd: targetCwd,
1089
+ timeoutMs: 10_000,
1090
+ });
1091
+ if (!r.ok) {
1092
+ return { ok: false, error: r.error || '设置默认基准仓库失败' };
1093
+ }
1094
+ return { ok: true, target: clean };
1095
+ }
1096
+ /**
1097
+ * 获取 Pull Request 完整详情与变更 Diff 数据(用于 PR 在线审阅与代码比对)。
1098
+ *
1099
+ * 架构设计:
1100
+ * 1. 严格参数白名单校验(PR 编号必须为正整数,repo 必须为合法 owner/repo 格式);
1101
+ * 2. 并行拉取元数据 (gh pr view) 与 Unified Diff (gh pr diff);
1102
+ * 3. 在内存通过 splitUnifiedDiff 对 Diff 原文按修改文件切片索引,
1103
+ * 前端点击任一文件即可 0ms 呈现代码行级增删对比,绝无二次网络往返。
1104
+ */
1105
+ async getPrDetail(cwd, prNumber, repo) {
1106
+ const targetCwd = this.resolveWorkspaceCwd(cwd);
1107
+ const num = Number(prNumber);
1108
+ if (!num || isNaN(num) || num <= 0) {
1109
+ return { ok: false, error: '缺少有效 PR 编号(必须为正整数)' };
1110
+ }
1111
+ const cleanRepo = this.normalizeRepoFullName(repo);
1112
+ if (cleanRepo === null) {
1113
+ return { ok: false, error: '非法仓库全名参数(仅允许 owner/repo 格式)' };
1114
+ }
1115
+ const viewArgs = [
1116
+ 'pr',
1117
+ 'view',
1118
+ String(num),
1119
+ '--json',
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',
1124
+ ];
1125
+ if (cleanRepo)
1126
+ viewArgs.push('-R', cleanRepo);
1127
+ const diffArgs = ['pr', 'diff', String(num)];
1128
+ if (cleanRepo)
1129
+ diffArgs.push('-R', cleanRepo);
1130
+ /**
1131
+ * 行级审查评论的抓取通道。
1132
+ *
1133
+ * 为什么单独走 API:`gh pr view --json comments` 只返回对话区(issue-level),
1134
+ * 挂在代码行上的 review comment 一条都拿不到。实测 cli/cli#14752 有 10 条
1135
+ * 行级评论而 `--json comments` 为零。
1136
+ */
1137
+ const fetchLineComments = async (fullName) => {
1138
+ if (!fullName)
1139
+ return [];
1140
+ const r = await this.run([`api`, `repos/${fullName}/pulls/${num}/comments?per_page=100`], { cwd: targetCwd, timeoutMs: 12_000 });
1141
+ if (!r.ok || !Array.isArray(r.data))
1142
+ return [];
1143
+ return r.data
1144
+ .filter((c) => c && typeof c.path === 'string')
1145
+ .map((c) => ({
1146
+ id: String(c.id ?? ''),
1147
+ path: String(c.path),
1148
+ line: typeof c.line === 'number' ? c.line : (typeof c.original_line === 'number' ? c.original_line : null),
1149
+ side: c.side ? String(c.side) : undefined,
1150
+ author: { login: String(c.user?.login || 'unknown') },
1151
+ body: String(c.body || ''),
1152
+ createdAt: String(c.created_at || ''),
1153
+ }));
1154
+ };
1155
+ // 本地可推导出仓库全名时就三路并发(UI 通常已传入 repo),
1156
+ // 推导不出则先用空结果占位,待 view 返回后从权威 url 补拉。
1157
+ const repoGuess = cleanRepo || (this.getRepoIdentityFromLocal(targetCwd)?.nameWithOwner || '');
1158
+ const [rView, rDiff, guessedLineComments] = await Promise.all([
1159
+ this.run(viewArgs, { cwd: targetCwd, timeoutMs: 15_000 }),
1160
+ // PR diff 走程序化消费通道(按文件切片,一次只渲染一个文件),
1161
+ // 不进入模型上下文,因此显式放宽 24KB 默认阈值——实测普通 PR 的
1162
+ // diff 已达 39KB,沿用默认会被静默截断,导致后半数文件点开皆空。
1163
+ this.run(diffArgs, {
1164
+ cwd: targetCwd,
1165
+ timeoutMs: 25_000,
1166
+ rawText: true,
1167
+ maxOutputChars: 2_000_000,
1168
+ maxOutputBytes: 8 * 1024 * 1024,
1169
+ }),
1170
+ // 第三路:行级审查评论。本地能推导仓库名时与上面两路并发,不增加串行等待。
1171
+ repoGuess ? fetchLineComments(repoGuess) : Promise.resolve([]),
1172
+ ]);
1173
+ if (!rView.ok || !rView.data) {
1174
+ return { ok: false, error: rView.error || `获取 PR #${num} 详情失败` };
1175
+ }
1176
+ const raw = rView.data;
1177
+ // 本地推不出仓库名(无 repo 参数且非 Git 目录)时,用 view 返回的权威 URL
1178
+ // 补拉一次行级评论。这是少见路径,多一次往返换来评论不丢,值得。
1179
+ let lineComments = guessedLineComments;
1180
+ if (!repoGuess) {
1181
+ const m = /github\.com\/([^/]+)\/([^/]+)\/pull\//i.exec(String(raw.url || ''));
1182
+ if (m)
1183
+ lineComments = await fetchLineComments(`${m[1]}/${m[2]}`);
1184
+ }
1185
+ const fullDiff = rDiff.ok ? (typeof rDiff.data === 'string' ? rDiff.data : rDiff.rawOutput || '') : '';
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]);
1205
+ const prDetail = {
1206
+ number: Number(raw.number) || num,
1207
+ title: String(raw.title || ''),
1208
+ state: (raw.state || 'OPEN').toUpperCase(),
1209
+ isDraft: Boolean(raw.isDraft),
1210
+ url: String(raw.url || ''),
1211
+ createdAt: String(raw.createdAt || ''),
1212
+ updatedAt: String(raw.updatedAt || ''),
1213
+ author: {
1214
+ login: String(raw.author?.login || 'ghost'),
1215
+ avatarUrl: raw.author?.avatarUrl ? String(raw.author.avatarUrl) : undefined,
1216
+ },
1217
+ headRefName: String(raw.headRefName || ''),
1218
+ baseRefName: String(raw.baseRefName || ''),
1219
+ headRefOid: raw.headRefOid ? String(raw.headRefOid) : undefined,
1220
+ baseRefOid: raw.baseRefOid ? String(raw.baseRefOid) : undefined,
1221
+ headRepoOwner: raw.headRepositoryOwner?.login ? String(raw.headRepositoryOwner.login) : undefined,
1222
+ body: String(raw.body || ''),
1223
+ reviewDecision: raw.reviewDecision ? String(raw.reviewDecision) : undefined,
1224
+ statusCheckRollup: raw.statusCheckRollup,
1225
+ additions: Number(raw.additions) || 0,
1226
+ deletions: Number(raw.deletions) || 0,
1227
+ files,
1228
+ changedFiles,
1229
+ ...(filesTruncated ? { filesTruncated: true } : {}),
1230
+ ...(diffIncomplete ? { diffIncomplete: true } : {}),
1231
+ comments: Array.isArray(raw.comments)
1232
+ ? raw.comments.map((c) => ({
1233
+ id: String(c.id || ''),
1234
+ author: {
1235
+ login: String(c.author?.login || 'unknown'),
1236
+ avatarUrl: c.author?.avatarUrl ? String(c.author.avatarUrl) : undefined,
1237
+ },
1238
+ authorAssociation: c.authorAssociation ? String(c.authorAssociation) : undefined,
1239
+ body: String(c.body || ''),
1240
+ createdAt: String(c.createdAt || ''),
1241
+ url: String(c.url || ''),
1242
+ }))
1243
+ : [],
1244
+ reviews: Array.isArray(raw.reviews)
1245
+ ? raw.reviews.map((rv) => ({
1246
+ id: String(rv.id || ''),
1247
+ author: { login: String(rv.author?.login || 'unknown') },
1248
+ state: String(rv.state || ''),
1249
+ body: String(rv.body || ''),
1250
+ submittedAt: String(rv.submittedAt || ''),
1251
+ }))
1252
+ : [],
1253
+ fileDiffs,
1254
+ lineComments,
1255
+ ...(rDiff.truncated ? { diffTruncated: true } : {}),
1256
+ };
1257
+ return { ok: true, data: prDetail };
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
+ }
1345
+ /**
1346
+ * 获取 Issue 完整详情(供 IssueDetailModal 在线查看消费)。
1347
+ *
1348
+ * 架构设计:
1349
+ * 1. 严格参数白名单(编号必须为正整数,repo 必须为 owner/repo 形态)——与
1350
+ * getPrDetail 同一套规则,杜绝任何形式的命令/路径注入;
1351
+ * 2. 单次 `gh issue view --json` 往返即可拿到正文、评论时间线、里程碑与关联 PR,
1352
+ * 不拆成多路并发(Issue 没有 diff 这类重载荷,多开进程反而增加失败面);
1353
+ * 3. 老版本 gh 遇未知字段会整体失败,因此保留 BASE 字段集降级链。
1354
+ */
1355
+ async getIssueDetail(cwd, issueNumber, repo) {
1356
+ const targetCwd = this.resolveWorkspaceCwd(cwd);
1357
+ const num = Number(issueNumber);
1358
+ if (!num || isNaN(num) || num <= 0 || !Number.isInteger(num)) {
1359
+ return { ok: false, error: '缺少有效 Issue 编号(必须为正整数)' };
1360
+ }
1361
+ const cleanRepo = this.normalizeRepoFullName(repo);
1362
+ if (cleanRepo === null) {
1363
+ return { ok: false, error: '非法仓库全名参数(仅允许 owner/repo 格式)' };
1364
+ }
1365
+ const buildArgs = (fields) => {
1366
+ const args = ['issue', 'view', String(num), '--json', fields];
1367
+ if (cleanRepo)
1368
+ args.push('-R', cleanRepo);
1369
+ return args;
1370
+ };
1371
+ /**
1372
+ * 输出阈值必须显式放宽。
1373
+ *
1374
+ * 为什么:executor 默认 maxOutputChars=24000,实测含 149 条评论的 Issue
1375
+ * JSON 达 117KB;一旦被截断,run() 会跳过 JSON.parse,data 恒为 undefined,
1376
+ * 于是弹窗只能显示「元数据全空 + 零评论」的假详情——比报错更糟。
1377
+ * 放宽到 2M/8MB 与 getPrDetail 的 diff 通道同口径。
1378
+ */
1379
+ const execOpts = {
1380
+ cwd: targetCwd,
1381
+ timeoutMs: 20_000,
1382
+ maxOutputChars: 2_000_000,
1383
+ maxOutputBytes: 8 * 1024 * 1024,
1384
+ };
1385
+ let fieldsDegraded = false;
1386
+ let r = await this.run(buildArgs(ISSUE_DETAIL_FULL_FIELDS), execOpts);
1387
+ // 老版本 gh 会以 `Unknown JSON field: "closedAt"` 整体拒绝:
1388
+ // 这不是网络问题,也不该让整个功能不可用——退回长期稳定字段集重试一次。
1389
+ if (!r.ok && /Unknown JSON field/i.test(String(r.error || ''))) {
1390
+ fieldsDegraded = true;
1391
+ r = await this.run(buildArgs(ISSUE_DETAIL_BASE_FIELDS), execOpts);
1392
+ }
1393
+ if (!r.ok) {
1394
+ return { ok: false, error: r.error || `获取 Issue #${num} 详情失败` };
1395
+ }
1396
+ // 截断 / 解析失败都必须显式失败:静默返回空评论会被误读成「没有讨论」。
1397
+ if (r.truncated || !r.data || typeof r.data !== 'object') {
1398
+ return {
1399
+ ok: false,
1400
+ error: `Issue #${num} 的讨论内容过长,已超出插件单次读取上限。请在浏览器中查看完整讨论(${cleanRepo ? `https://github.com/${cleanRepo}/issues/${num}` : '仓库 Issues 页面'})。`,
1401
+ };
1402
+ }
1403
+ const raw = r.data;
1404
+ const comments = Array.isArray(raw.comments)
1405
+ ? raw.comments.map((c) => ({
1406
+ id: String(c.id || ''),
1407
+ author: {
1408
+ login: String(c.author?.login || 'ghost'),
1409
+ avatarUrl: c.author?.avatarUrl ? String(c.author.avatarUrl) : undefined,
1410
+ },
1411
+ authorAssociation: c.authorAssociation ? String(c.authorAssociation) : undefined,
1412
+ body: String(c.body || ''),
1413
+ createdAt: String(c.createdAt || ''),
1414
+ url: String(c.url || ''),
1415
+ ...(c.isMinimized === true ? { isMinimized: true } : {}),
1416
+ ...(c.minimizedReason ? { minimizedReason: String(c.minimizedReason) } : {}),
1417
+ ...(c.includesCreatedEdit === true ? { includesCreatedEdit: true } : {}),
1418
+ }))
1419
+ : [];
1420
+ const body = String(raw.body || '');
1421
+ // 图片清单:正文 + 全部评论合并去重,与 github_issue(view_images) 同源
1422
+ const imageSources = [body];
1423
+ for (const c of comments)
1424
+ imageSources.push(c.body);
1425
+ const images = Array.from(new Set(imageSources.flatMap((b) => extractImageUrls(b))));
1426
+ const linkedPrs = Array.isArray(raw.closedByPullRequestsReferences)
1427
+ ? raw.closedByPullRequestsReferences
1428
+ .filter((p) => p && Number(p.number) > 0)
1429
+ .map((p) => {
1430
+ const repoName = p.repository?.name && p.repository?.owner?.login
1431
+ ? `${p.repository.owner.login}/${p.repository.name}`
1432
+ : undefined;
1433
+ return {
1434
+ number: Number(p.number),
1435
+ url: String(p.url || ''),
1436
+ ...(repoName ? { repository: repoName } : {}),
1437
+ };
1438
+ })
1439
+ : [];
1440
+ const detail = {
1441
+ number: Number(raw.number) || num,
1442
+ title: String(raw.title || ''),
1443
+ state: String(raw.state || 'OPEN').toUpperCase() === 'CLOSED' ? 'CLOSED' : 'OPEN',
1444
+ stateReason: raw.stateReason ? String(raw.stateReason) : undefined,
1445
+ url: String(raw.url || ''),
1446
+ createdAt: String(raw.createdAt || ''),
1447
+ updatedAt: String(raw.updatedAt || ''),
1448
+ closedAt: raw.closedAt ? String(raw.closedAt) : undefined,
1449
+ author: {
1450
+ login: String(raw.author?.login || 'ghost'),
1451
+ avatarUrl: raw.author?.avatarUrl ? String(raw.author.avatarUrl) : undefined,
1452
+ },
1453
+ body,
1454
+ labels: Array.isArray(raw.labels)
1455
+ ? raw.labels.map((l) => ({
1456
+ name: String(l.name || ''),
1457
+ ...(l.color ? { color: String(l.color) } : {}),
1458
+ ...(l.description ? { description: String(l.description) } : {}),
1459
+ }))
1460
+ : [],
1461
+ assignees: Array.isArray(raw.assignees)
1462
+ ? raw.assignees
1463
+ .map((a) => ({ login: String(a.login || '') }))
1464
+ .filter((a) => a.login)
1465
+ : [],
1466
+ ...(raw.milestone && raw.milestone.title
1467
+ ? {
1468
+ milestone: {
1469
+ ...(typeof raw.milestone.number === 'number' ? { number: raw.milestone.number } : {}),
1470
+ title: String(raw.milestone.title),
1471
+ ...(raw.milestone.dueOn ? { dueOn: String(raw.milestone.dueOn) } : {}),
1472
+ },
1473
+ }
1474
+ : {}),
1475
+ ...(raw.isPinned === true ? { isPinned: true } : {}),
1476
+ ...(raw.issueType?.name ? { issueType: String(raw.issueType.name) } : {}),
1477
+ comments,
1478
+ linkedPrs,
1479
+ ...(raw.subIssuesSummary && typeof raw.subIssuesSummary.total === 'number'
1480
+ ? {
1481
+ subIssues: {
1482
+ completed: Number(raw.subIssuesSummary.completed) || 0,
1483
+ total: Number(raw.subIssuesSummary.total) || 0,
1484
+ },
1485
+ }
1486
+ : {}),
1487
+ images,
1488
+ ...(fieldsDegraded ? { fieldsDegraded: true } : {}),
1489
+ };
1490
+ return { ok: true, data: detail };
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
+ }
702
1735
  /** 生成当前操作系统平台的推荐安装引导与命令 */
703
1736
  getPlatformInstallGuide() {
704
1737
  return getPlatformInstallGuide();
@@ -828,41 +1861,208 @@ export class GhExecutor {
828
1861
  };
829
1862
  }
830
1863
  /**
831
- * 仅从本地 .git/config 解析仓库身份,绝不发起网络请求。
1864
+ * 从 `refs/remotes/<remote>/HEAD` 符号引用中读取仓库默认分支。
832
1865
  *
833
- * 为什么需要它:全局驾驶舱要为每个工作区认识别仓库,而 getRepoMetadata 会调用
834
- * `gh repo view` 走网络。实测 5 个工作区时该端点冷启动 20.5 秒,其中绝大部分耗在这 5 次
835
- * 网络往返上——而有界并发根本救不了它,因为瓶颈是网络延迟而不是进程排队。
836
- * 工作区矩阵只需要「这个目录属于哪个仓库」这一身份信息,本地 .git/config 已经完整具备,
837
- * 描述/star 数等富字段是当前仓库详情才需要的。这一步把每个工作区从 1 次网络往返降为 0。
1866
+ * 为什么只认这一个来源(其余全部实测排除):
1867
+ * - `packed-refs` 只存具体 sha,不含 symbolic ref —— 读不到;
1868
+ * - 当前 HEAD 指向的是**用户 checkout 的分支**,不是仓库默认分支,语义错误;
1869
+ * - 猜 main/master —— 无依据,宁可回落固定值也不猜。
1870
+ *
1871
+ * @returns 分支名;文件不存在(未 fetch 过)或内容不是符号引用时返回 null,
1872
+ * 由调用方回落到既有默认值(保持原有行为,不引入新错误)
838
1873
  */
839
- getRepoIdentityFromLocal(cwd) {
1874
+ readDefaultBranchFromRef(remoteName, cwd) {
1875
+ try {
1876
+ const info = this.resolveGitDirInfo(cwd);
1877
+ if (!info)
1878
+ return null;
1879
+ // refs 存放在共享目录(worktree 与主仓库共用),已实测
1880
+ const headRefPath = path.join(info.commonDir, 'refs', 'remotes', remoteName, 'HEAD');
1881
+ if (!fs.existsSync(headRefPath))
1882
+ return null;
1883
+ const raw = fs.readFileSync(headRefPath, 'utf8');
1884
+ // 内容形如 `ref: refs/remotes/origin/main`
1885
+ const m = /^\s*ref:\s*refs\/remotes\/[^/]+\/(.+?)\s*$/m.exec(raw);
1886
+ return m ? m[1] : null;
1887
+ }
1888
+ catch {
1889
+ return null;
1890
+ }
1891
+ }
1892
+ /**
1893
+ * 解析工作区真实的 git 目录的**两个**路径:局部目录与共享仓库目录。
1894
+ *
1895
+ * 为什么必须分开:worktree 的 `HEAD` 在局部目录(`.git/worktrees/<name>/HEAD`),
1896
+ * 而 `config` 在共享目录(`commondir` 指向处)。只解析其中一个必然读错:
1897
+ * - 只读局部 → 找不到 config(remote 配置在共享目录)
1898
+ * - 只读共享 → 拿到**主工作树**的分支名,进而按错误分支查 `branch.*.remote`
1899
+ *
1900
+ * 实测:主工作树 HEAD 是 `ref: refs/heads/master`,worktree 的是 `ref: refs/heads/feat`。
1901
+ *
1902
+ * 常规仓库两者指向同一目录。
1903
+ *
1904
+ * @returns 两个绝对路径;非 Git 目录/指针不可解析时返回 null(绝不抛错)
1905
+ */
1906
+ resolveGitDirInfo(cwd) {
840
1907
  const targetCwd = cwd || this.resolveWorkspaceCwd();
841
1908
  try {
842
- const gitConfigPath = path.join(targetCwd, '.git', 'config');
843
- if (!fs.existsSync(gitConfigPath))
1909
+ const dotGit = path.join(targetCwd, '.git');
1910
+ if (!fs.existsSync(dotGit))
844
1911
  return null;
845
- const configText = fs.readFileSync(gitConfigPath, 'utf8');
846
- const match = configText.match(/github\.com[:/]([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git)?(?:\s|\r|\n|$)/i);
847
- if (!match)
1912
+ const st = fs.statSync(dotGit);
1913
+ if (st.isDirectory())
1914
+ return { localDir: dotGit, commonDir: dotGit };
1915
+ if (!st.isFile())
848
1916
  return null;
849
- const owner = match[1];
850
- const repoName = match[2];
851
- const nameWithOwner = `${owner}/${repoName}`;
852
- return {
853
- nameWithOwner,
854
- name: repoName,
855
- owner,
856
- defaultBranch: 'main',
857
- description: '',
858
- isPrivate: false,
859
- url: `https://github.com/${nameWithOwner}`,
860
- };
1917
+ // worktree/submodule:.git 是文本文件,内容形如 `gitdir: C:/repo/.git/worktrees/wt`
1918
+ const pointer = fs.readFileSync(dotGit, 'utf8');
1919
+ const m = /^\s*gitdir:\s*(.+?)\s*$/m.exec(pointer);
1920
+ if (!m)
1921
+ return null;
1922
+ const gitDirRaw = m[1].replace(/[\\/]+$/, '');
1923
+ const localDir = path.isAbsolute(gitDirRaw) ? path.normalize(gitDirRaw) : path.resolve(targetCwd, gitDirRaw);
1924
+ if (!fs.existsSync(localDir))
1925
+ return null;
1926
+ // worktree:<localDir>/commondir 记录共享仓库目录(相对 localDir)
1927
+ const commonDirFile = path.join(localDir, 'commondir');
1928
+ if (fs.existsSync(commonDirFile)) {
1929
+ try {
1930
+ const rel = fs.readFileSync(commonDirFile, 'utf8').trim();
1931
+ if (rel) {
1932
+ const common = path.resolve(localDir, rel);
1933
+ if (fs.existsSync(path.join(common, 'config')))
1934
+ return { localDir, commonDir: common };
1935
+ }
1936
+ }
1937
+ catch {
1938
+ // commondir 读失败 → 退回 localDir(至少还能读到局部配置)
1939
+ }
1940
+ }
1941
+ return { localDir, commonDir: localDir };
1942
+ }
1943
+ catch {
1944
+ return null;
1945
+ }
1946
+ }
1947
+ /**
1948
+ * 解析工作区真实的 git 目录(共享仓库目录)。
1949
+ * 薄封装自 resolveGitDirInfo:读 `config` 的场景(远程指纹)只需要共享目录。
1950
+ */
1951
+ resolveGitDirPath(cwd) {
1952
+ return this.resolveGitDirInfo(cwd)?.commonDir ?? null;
1953
+ }
1954
+ /**
1955
+ * remote 配置指纹:全部 remote url 去重、排序、拼接后取 SHA-256 前 12 位。
1956
+ *
1957
+ * 为什么不用身份解析做缓存失效判定:身份解析要回答「变成了什么」,
1958
+ * 一旦解析器对某种 config 布局有偏差(如双 remote 时取错 remote 段),
1959
+ * 比对就永远失配——缓存再也不命中,每次请求都重拉全部 gh 子进程。
1960
+ * 指纹只回答「变了没有」,不依赖任何语义理解,因此对该类偏差免疫。
1961
+ *
1962
+ * 反例(实测):`git remote rename origin upstream` 后 `git remote add origin <fork>`,
1963
+ * config 里 upstream 段排在 origin 段前,取「第一个 github.com 匹配」会拿到上游,
1964
+ * 而 git 权威解析(branch.<name>.remote → origin)拿到的是 fork。
1965
+ *
1966
+ * @returns 12 位十六进制指纹;仅当「非 Git 目录 / config 不可读」时返回 null
1967
+ * (调用方据此保守判定为「未变更」,绝不因探测失败误清空缓存)。
1968
+ * 注意:**config 可读但没有任何 remote 时返回的是空集合的哈希,不是 null**——
1969
+ * 「仓库没有远程」是一个确定事实,它与「读不到配置」必须区分开:
1970
+ * 否则用户删光 remote 后指纹恒为 null,旧快照再也无法失效。
1971
+ */
1972
+ getRemoteFingerprint(cwd) {
1973
+ try {
1974
+ const gitDir = this.resolveGitDirPath(cwd);
1975
+ if (!gitDir)
1976
+ return null;
1977
+ const configPath = path.join(gitDir, 'config');
1978
+ if (!fs.existsSync(configPath))
1979
+ return null;
1980
+ const urls = parseRemoteUrls(fs.readFileSync(configPath, 'utf8'));
1981
+ // 排序后拼接:remote 数量、书写顺序、命名方式都不影响指纹——
1982
+ // 只有「url 集合本身」变化才会改变指纹,这正是「配置变没变」的定义。
1983
+ // 空集合同样参与哈希(得到空串的 SHA-256 前缀),语义是「已读、无 remote」,
1984
+ // 与读取失败的 null 是两回事。
1985
+ const normalized = [...urls].sort().join('\n');
1986
+ return crypto.createHash('sha256').update(normalized, 'utf8').digest('hex').slice(0, 12);
1987
+ }
1988
+ catch {
1989
+ return null;
1990
+ }
1991
+ }
1992
+ /**
1993
+ * 读取并解析工作区的 git 远程视图(`.git/config` + `HEAD`)。
1994
+ * 内部方法:把「两个路径各读哪个文件」的细节收敛在一处,
1995
+ * 避免各调用点自己拼路径读错 worktree 的文件。
1996
+ */
1997
+ readParsedGitConfig(cwd) {
1998
+ const info = this.resolveGitDirInfo(cwd);
1999
+ if (!info)
2000
+ return null;
2001
+ try {
2002
+ const configPath = path.join(info.commonDir, 'config');
2003
+ if (!fs.existsSync(configPath))
2004
+ return null;
2005
+ const configText = fs.readFileSync(configPath, 'utf8');
2006
+ // HEAD 必须从 localDir 读:worktree 下 commonDir/HEAD 是主工作树的分支名
2007
+ const headPath = path.join(info.localDir, 'HEAD');
2008
+ const headText = fs.existsSync(headPath) ? fs.readFileSync(headPath, 'utf8') : null;
2009
+ return parseGitConfig(configText, headText);
861
2010
  }
862
2011
  catch {
863
2012
  return null;
864
2013
  }
865
2014
  }
2015
+ /**
2016
+ * 仅从本地 `.git/config` 解析仓库身份,绝不发起网络请求。
2017
+ *
2018
+ * 为什么需要它:全局驾驶舱要为每个工作区认识别仓库,而 getRepoMetadata 会调用
2019
+ * `gh repo view` 走网络。实测 5 个工作区时该端点冷启动 20.5 秒,其中绝大部分耗在这 5 次
2020
+ * 网络往返上——而有界并发根本救不了它,因为瓶颈是网络延迟而不是进程排队。
2021
+ * 工作区矩阵只需要「这个目录属于哪个仓库」这一身份信息,本地 .git/config 已经完整具备,
2022
+ * 描述/star 数等富字段是当前仓库详情才需要的。这一步把每个工作区从 1 次网络往返降为 0。
2023
+ *
2024
+ * 解析口径与 git 一致(三级回落),不再「取 config 里第一个 github.com 匹配」——
2025
+ * 后者在 `git remote rename origin upstream` + `add origin <fork>` 的布局下会取到上游,
2026
+ * 而 git 认的是 origin(实测)。
2027
+ */
2028
+ getRepoIdentityFromLocal(cwd) {
2029
+ const cfg = this.readParsedGitConfig(cwd);
2030
+ if (!cfg)
2031
+ return null;
2032
+ const layout = classifyRepoLayout(cfg);
2033
+ /**
2034
+ * 按布局分类选目标 remote —— 与 `.git/config` 书写顺序无关。
2035
+ *
2036
+ * github-only :完全按 git 规则(branch.<当前分支>.remote → origin → 首个)
2037
+ * github-mixed :git 选中的不是 GitHub → 在 GitHub remote 中按
2038
+ * origin 优先 → 名字字典序(pickGithubRemote)
2039
+ * 其余 :无 GitHub remote,返回 null,不猜测
2040
+ */
2041
+ let target = null;
2042
+ if (layout === 'github-only') {
2043
+ target = resolveRemoteTarget(cfg);
2044
+ }
2045
+ else if (layout === 'github-mixed') {
2046
+ target = pickGithubRemote(cfg);
2047
+ }
2048
+ if (!target)
2049
+ return null;
2050
+ const gh = parseGithubUrl(target.url);
2051
+ if (!gh)
2052
+ return null;
2053
+ const nameWithOwner = `${gh.owner}/${gh.repo}`;
2054
+ return {
2055
+ nameWithOwner,
2056
+ name: gh.repo,
2057
+ owner: gh.owner,
2058
+ // 默认分支:从该 remote 的 HEAD 符号引用推断,读不到才回落 'main'
2059
+ defaultBranch: this.readDefaultBranchFromRef(target.name, cwd) || 'main',
2060
+ description: '',
2061
+ isPrivate: false,
2062
+ url: `https://github.com/${nameWithOwner}`,
2063
+ layout,
2064
+ };
2065
+ }
866
2066
  /**
867
2067
  * 获取当前目录(工作区)关联的远程仓库元数据
868
2068
  * 采用 gh repo view + 本地 git remote 双保险机制,彻底杜绝已配置远程却误判未关联的问题
@@ -875,61 +2075,32 @@ export class GhExecutor {
875
2075
  return null;
876
2076
  }
877
2077
  // 1. 优先尝试由 gh 直接根据当前上下文识别
878
- const res = await this.run(['repo', 'view', '--json', 'nameWithOwner,name,owner,defaultBranchRef,description,isPrivate,url,stargazerCount,forkCount'], { cwd: targetCwd, timeoutMs: 10_000 });
2078
+ const res = await this.run(['repo', 'view', '--json', REPO_VIEW_FIELDS], { cwd: targetCwd, timeoutMs: 10_000 });
879
2079
  if (res.ok && res.data && res.data.nameWithOwner) {
880
- const d = res.data;
881
- return {
882
- nameWithOwner: d.nameWithOwner || '',
883
- name: d.name || '',
884
- owner: d.owner?.login || '',
885
- defaultBranch: d.defaultBranchRef?.name || 'main',
886
- description: d.description || '',
887
- isPrivate: Boolean(d.isPrivate),
888
- url: d.url || `https://github.com/${d.nameWithOwner}`,
889
- stargazerCount: typeof d.stargazerCount === 'number' ? d.stargazerCount : 0,
890
- forkCount: typeof d.forkCount === 'number' ? d.forkCount : 0,
891
- };
2080
+ return mapRepoMetadata(res.data);
892
2081
  }
893
- // 2. 原生文件解析:直接读取本地 .git/config 文件(0 子进程开销、免疫沙箱 EPERM)
894
- try {
895
- const gitConfigPath = path.join(targetCwd, '.git', 'config');
896
- if (fs.existsSync(gitConfigPath)) {
897
- const configText = fs.readFileSync(gitConfigPath, 'utf8');
898
- const match = configText.match(/github\.com[:/]([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git)?(?:\s|\r|\n|$)/i);
899
- if (match) {
900
- const owner = match[1];
901
- const repoName = match[2];
902
- const nameWithOwner = `${owner}/${repoName}`;
903
- // 使用精确的 owner/repo 向 gh 获取元数据(显式传参,不依赖无上下文的探测)
904
- const directRes = await this.run(['repo', 'view', nameWithOwner, '--json', 'nameWithOwner,name,owner,defaultBranchRef,description,isPrivate,url,stargazerCount,forkCount'], { cwd: targetCwd, timeoutMs: 10_000 });
905
- if (directRes.ok && directRes.data && directRes.data.nameWithOwner) {
906
- const d = directRes.data;
907
- return {
908
- nameWithOwner: d.nameWithOwner || nameWithOwner,
909
- name: d.name || repoName,
910
- owner: d.owner?.login || owner,
911
- defaultBranch: d.defaultBranchRef?.name || 'main',
912
- description: d.description || '',
913
- isPrivate: Boolean(d.isPrivate),
914
- url: d.url || `https://github.com/${nameWithOwner}`,
915
- stargazerCount: typeof d.stargazerCount === 'number' ? d.stargazerCount : 0,
916
- forkCount: typeof d.forkCount === 'number' ? d.forkCount : 0,
917
- };
918
- }
919
- // 若离线或网络受限,由本地 git remote 配置兜底返回
920
- return {
921
- nameWithOwner,
922
- name: repoName,
923
- owner,
924
- defaultBranch: 'main',
925
- description: '已成功关联本地 Git 远程仓库',
926
- isPrivate: false,
927
- url: `https://github.com/${nameWithOwner}`,
928
- };
929
- }
2082
+ // 2. 原生文件解析:走与 getRepoIdentityFromLocal 完全相同的解析器
2083
+ // (0 子进程开销、免疫沙箱 EPERM)。
2084
+ // 为什么不再自己写一份正则:两处各维护一份必然漂移,且旧正则
2085
+ // 「取第一个 github.com 匹配」在多 remote 布局下会取到上游仓库。
2086
+ const local = this.getRepoIdentityFromLocal(targetCwd);
2087
+ if (local) {
2088
+ const { nameWithOwner } = local;
2089
+ // 使用精确的 owner/repo 向 gh 获取元数据(显式传参,不依赖无上下文的探测)
2090
+ const directRes = await this.run(['repo', 'view', nameWithOwner, '--json', REPO_VIEW_FIELDS], {
2091
+ cwd: targetCwd,
2092
+ timeoutMs: 10_000,
2093
+ });
2094
+ if (directRes.ok && directRes.data && directRes.data.nameWithOwner) {
2095
+ return mapRepoMetadata(directRes.data, nameWithOwner);
930
2096
  }
2097
+ // 若离线或网络受限,由本地 git remote 配置兜底返回。
2098
+ // 注意:此处不带 isFork / parent / viewerPermission —— 离线无从得知,绝不猜测。
2099
+ return {
2100
+ ...local,
2101
+ description: '已成功关联本地 Git 远程仓库',
2102
+ };
931
2103
  }
932
- catch { }
933
2104
  return null;
934
2105
  }
935
2106
  }