dsh-github-flow 0.4.0 → 0.6.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 (82) hide show
  1. package/README.en.md +91 -12
  2. package/README.md +91 -12
  3. package/docs/UPGRADE-NOTES.md +9 -12
  4. package/docs/architecture.md +63 -29
  5. package/docs/images/1.png +0 -0
  6. package/lib/api/routes-actions.js +268 -5
  7. package/lib/api/routes-overview.js +147 -29
  8. package/lib/cache.d.ts +77 -4
  9. package/lib/cache.js +114 -7
  10. package/lib/client.d.ts +9 -4
  11. package/lib/client.js +5293 -942
  12. package/lib/commands/gh.d.ts +6 -1
  13. package/lib/commands/gh.js +144 -71
  14. package/lib/executor/error-classifier.d.ts +55 -0
  15. package/lib/executor/error-classifier.js +199 -0
  16. package/lib/executor/git-parser.d.ts +157 -1
  17. package/lib/executor/git-parser.js +513 -4
  18. package/lib/executor.d.ts +147 -4
  19. package/lib/executor.js +866 -77
  20. package/lib/main.js +28 -4
  21. package/lib/renderers/agent-task.d.ts +16 -0
  22. package/lib/renderers/agent-task.js +107 -0
  23. package/lib/renderers/codespace.d.ts +16 -0
  24. package/lib/renderers/codespace.js +103 -0
  25. package/lib/renderers/discussion.d.ts +16 -0
  26. package/lib/renderers/discussion.js +101 -0
  27. package/lib/renderers/markdown.d.ts +4 -0
  28. package/lib/renderers/markdown.js +4 -0
  29. package/lib/renderers/project.d.ts +20 -0
  30. package/lib/renderers/project.js +128 -0
  31. package/lib/renderers/skill.d.ts +30 -0
  32. package/lib/renderers/skill.js +180 -0
  33. package/lib/renderers/status.d.ts +5 -0
  34. package/lib/renderers/status.js +52 -0
  35. package/lib/telemetry.d.ts +70 -0
  36. package/lib/telemetry.js +161 -0
  37. package/lib/tools/agent-task.d.ts +54 -0
  38. package/lib/tools/agent-task.js +135 -0
  39. package/lib/tools/codespace.d.ts +51 -0
  40. package/lib/tools/codespace.js +154 -0
  41. package/lib/tools/discussion.d.ts +70 -0
  42. package/lib/tools/discussion.js +195 -0
  43. package/lib/tools/help.js +129 -14
  44. package/lib/tools/image-utils.d.ts +27 -0
  45. package/lib/tools/image-utils.js +73 -3
  46. package/lib/tools/project.d.ts +80 -0
  47. package/lib/tools/project.js +241 -0
  48. package/lib/tools/repo.js +13 -1
  49. package/lib/tools/skill.d.ts +24 -1
  50. package/lib/tools/skill.js +115 -5
  51. package/lib/tools/status.d.ts +3 -2
  52. package/lib/tools/status.js +9 -4
  53. package/lib/types.d.ts +336 -0
  54. package/lib/ui/commit-modal.js +144 -14
  55. package/lib/ui/help-modal.d.ts +21 -0
  56. package/lib/ui/help-modal.js +300 -0
  57. package/lib/ui/issue-modal.d.ts +14 -0
  58. package/lib/ui/issue-modal.js +366 -0
  59. package/lib/ui/markdown-lite.d.ts +66 -0
  60. package/lib/ui/markdown-lite.js +312 -0
  61. package/lib/ui/network-banner.d.ts +12 -0
  62. package/lib/ui/network-banner.js +35 -0
  63. package/lib/ui/pr-modal.d.ts +17 -0
  64. package/lib/ui/pr-modal.js +516 -0
  65. package/lib/ui/repo-badges.d.ts +43 -0
  66. package/lib/ui/repo-badges.js +82 -0
  67. package/lib/ui/repo-selector.d.ts +14 -0
  68. package/lib/ui/repo-selector.js +112 -0
  69. package/lib/ui/storage.d.ts +56 -0
  70. package/lib/ui/storage.js +163 -0
  71. package/lib/ui/styles.d.ts +1 -1
  72. package/lib/ui/styles.js +1219 -0
  73. package/lib/ui/sync-settings-modal.js +1 -1
  74. package/lib/ui/upgrade-card.js +39 -14
  75. package/lib/ui/utils.d.ts +35 -0
  76. package/lib/ui/utils.js +172 -0
  77. package/lib/ui/workspace-panel.js +198 -31
  78. package/lib/upgrade-manager.d.ts +5 -1
  79. package/lib/upgrade-manager.js +24 -1
  80. package/lib/version.d.ts +1 -1
  81. package/lib/version.js +1 -1
  82. package/package.json +2 -2
@@ -141,8 +141,12 @@ export function registerActionRoutes(deps, executor, cacheMap, globalCache) {
141
141
  kind: 'exact',
142
142
  path: '/api/github/refresh',
143
143
  handler: async (req, res) => {
144
- cacheMap.clear();
145
- globalCache.clear();
144
+ // 连容灾底座一起清:用户点「重试刷新」的语义是「宁可空白也重来」。
145
+ // 只清活动缓存会留下一个致命死角——失败分支从不写缓存,于是 fallback
146
+ // 里的旧快照一旦冻结就永久生效(仓库身份改错了也永远出不来),
147
+ // 刷新按钮点了等于没点。
148
+ cacheMap.clear({ keepFallback: false });
149
+ globalCache.clear({ keepFallback: false });
146
150
  sendJson(res, 200, { ok: true, message: '缓存已清空,下次读取将拉取最新数据' });
147
151
  },
148
152
  });
@@ -155,9 +159,30 @@ export function registerActionRoutes(deps, executor, cacheMap, globalCache) {
155
159
  sendJson(res, 405, { ok: false, message: '仅支持 POST 请求' });
156
160
  return;
157
161
  }
162
+ // 请求体上限:这些 action 的载荷都是短参数(标题/分支/PR 编号),
163
+ // 正常远小于 1KB。裸 `body += chunk` 无上限意味着一个超大 POST 能把
164
+ // 整个请求体堆进进程内存——本地服务也不该留这种自伤通道。
165
+ const MAX_ACTION_BODY_BYTES = 256 * 1024;
158
166
  let body = '';
159
- req.on('data', (chunk) => { body += chunk; });
167
+ let bodyBytes = 0;
168
+ let bodyTooLarge = false;
169
+ req.on('data', (chunk) => {
170
+ if (bodyTooLarge)
171
+ return;
172
+ bodyBytes += chunk.length;
173
+ if (bodyBytes > MAX_ACTION_BODY_BYTES) {
174
+ bodyTooLarge = true;
175
+ body = '';
176
+ // 报 KB 而非字节数:262144 这种数字对人没有意义
177
+ sendJson(res, 413, { ok: false, error: `请求体超过 ${MAX_ACTION_BODY_BYTES / 1024}KB 上限` });
178
+ req.destroy();
179
+ return;
180
+ }
181
+ body += chunk;
182
+ });
160
183
  req.on('end', async () => {
184
+ if (bodyTooLarge)
185
+ return;
161
186
  try {
162
187
  const payload = body ? JSON.parse(body) : {};
163
188
  const cwd = payload.cwd || process.cwd();
@@ -243,6 +268,67 @@ export function registerActionRoutes(deps, executor, cacheMap, globalCache) {
243
268
  sendJson(res, 200, { ok: true, currentBranch: r.currentBranch, message: `已成功切换到分支 ${r.currentBranch}` });
244
269
  break;
245
270
  }
271
+ case 'set_default_repo':
272
+ case 'set_active_repo': {
273
+ const target = typeof payload.repo === 'string' ? payload.repo.trim() : (typeof payload.remote === 'string' ? payload.remote.trim() : '');
274
+ if (!target) {
275
+ sendJson(res, 400, { ok: false, error: '缺少目标仓库全名或 remote 别名' });
276
+ return;
277
+ }
278
+ const r = await executor.setDefaultRepo(cwd, target);
279
+ if (!r.ok) {
280
+ sendJson(res, 400, { ok: false, error: r.error });
281
+ return;
282
+ }
283
+ // 彻底重置缓存(包含容灾快照),使下一次概览请求立即以新基准重新发起探测
284
+ cacheMap.clear({ keepFallback: false });
285
+ globalCache.clear({ keepFallback: false });
286
+ sendJson(res, 200, {
287
+ ok: true,
288
+ target: r.target,
289
+ message: `已成功将基准仓库切换为 ${r.target}`,
290
+ });
291
+ break;
292
+ }
293
+ case 'file_diff':
294
+ case 'get_file_diff': {
295
+ const fileTarget = typeof payload.file === 'string' ? payload.file.trim() : (typeof payload.filePath === 'string' ? payload.filePath.trim() : '');
296
+ if (!fileTarget) {
297
+ sendJson(res, 400, { ok: false, error: '缺少目标文件路径' });
298
+ return;
299
+ }
300
+ const resolvedCwd = executor.resolveWorkspaceCwd(cwd);
301
+ const r = await executor.getFileDiff(resolvedCwd, fileTarget);
302
+ sendJson(res, 200, { ok: r.ok, data: r, error: r.error });
303
+ break;
304
+ }
305
+ case 'uncommitted_files':
306
+ case 'get_uncommitted_files': {
307
+ const limit = Math.min(Math.max(1, Number(payload.limit) || 15), 100);
308
+ const skip = Math.max(0, Number(payload.skip) || 0);
309
+ const resolvedCwd = executor.resolveWorkspaceCwd(cwd);
310
+ const r = await executor.getUncommittedFiles(resolvedCwd, limit, skip);
311
+ sendJson(res, 200, { ok: true, data: r });
312
+ break;
313
+ }
314
+ case 'pr_detail':
315
+ case 'get_pr_detail': {
316
+ const prNumber = Number(payload.number || payload.prNumber);
317
+ const targetRepo = typeof payload.repo === 'string' ? payload.repo.trim() : undefined;
318
+ const r = await executor.getPrDetail(cwd, prNumber, targetRepo);
319
+ sendJson(res, r.ok ? 200 : 400, r);
320
+ break;
321
+ }
322
+ case 'issue_detail':
323
+ case 'get_issue_detail': {
324
+ // 与 pr_detail 对称的 Issue 详情通道:供 POST 调用方(Agent/脚本)复用,
325
+ // 与 GET /api/github/issue-detail 共用同一个 executor 方法。
326
+ const issueNumber = Number(payload.number || payload.issueNumber || payload.issue_number);
327
+ const targetRepo = typeof payload.repo === 'string' ? payload.repo.trim() : undefined;
328
+ const r = await executor.getIssueDetail(cwd, issueNumber, targetRepo);
329
+ sendJson(res, r.ok ? 200 : 400, r);
330
+ break;
331
+ }
246
332
  default:
247
333
  sendJson(res, 400, { ok: false, message: `未知的操作类型: ${payload.action}` });
248
334
  }
@@ -286,9 +372,30 @@ export function registerActionRoutes(deps, executor, cacheMap, globalCache) {
286
372
  sendJson(res, 405, { ok: false, message: '仅支持 POST 请求' });
287
373
  return;
288
374
  }
375
+ // 请求体上限:这些 action 的载荷都是短参数(标题/分支/PR 编号),
376
+ // 正常远小于 1KB。裸 `body += chunk` 无上限意味着一个超大 POST 能把
377
+ // 整个请求体堆进进程内存——本地服务也不该留这种自伤通道。
378
+ const MAX_ACTION_BODY_BYTES = 256 * 1024;
289
379
  let body = '';
290
- req.on('data', (chunk) => { body += chunk; });
380
+ let bodyBytes = 0;
381
+ let bodyTooLarge = false;
382
+ req.on('data', (chunk) => {
383
+ if (bodyTooLarge)
384
+ return;
385
+ bodyBytes += chunk.length;
386
+ if (bodyBytes > MAX_ACTION_BODY_BYTES) {
387
+ bodyTooLarge = true;
388
+ body = '';
389
+ // 报 KB 而非字节数:262144 这种数字对人没有意义
390
+ sendJson(res, 413, { ok: false, error: `请求体超过 ${MAX_ACTION_BODY_BYTES / 1024}KB 上限` });
391
+ req.destroy();
392
+ return;
393
+ }
394
+ body += chunk;
395
+ });
291
396
  req.on('end', async () => {
397
+ if (bodyTooLarge)
398
+ return;
292
399
  try {
293
400
  const payload = body ? JSON.parse(body) : {};
294
401
  const cwd = payload.cwd || undefined;
@@ -316,7 +423,52 @@ export function registerActionRoutes(deps, executor, cacheMap, globalCache) {
316
423
  });
317
424
  },
318
425
  });
319
- // 5. 本地 Git 元数据定向监听(专供右侧栏开启「本地 Git 改动实时感知」时长轮询)
426
+ // 5. PR 在线详情与代码差异比对(在线代码审阅专用,单次往返聚合元数据、评论流与按文件切片的 Unified Diff)
427
+ bindRoute({
428
+ kind: 'exact',
429
+ path: '/api/github/pr-detail',
430
+ handler: async (req, res) => {
431
+ if (req.method !== 'GET') {
432
+ sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
433
+ return;
434
+ }
435
+ try {
436
+ const u = new URL(req.url || '', 'http://127.0.0.1');
437
+ const number = Number(u.searchParams.get('number') || u.searchParams.get('prNumber'));
438
+ const cwd = u.searchParams.get('cwd') || undefined;
439
+ const repo = u.searchParams.get('repo') || undefined;
440
+ const r = await executor.getPrDetail(cwd, number, repo);
441
+ sendJson(res, r.ok ? 200 : 400, r);
442
+ }
443
+ catch (err) {
444
+ sendJson(res, 500, { ok: false, error: err?.message || '读取 PR 详情异常' });
445
+ }
446
+ },
447
+ });
448
+ // 6. Issue 在线详情(正文 / 评论时间线 / 标签 / 里程碑 / 关联 PR)
449
+ // 与 pr-detail 同构:GET 读语义、单次往返、参数白名单校验全部下沉到 executor。
450
+ bindRoute({
451
+ kind: 'exact',
452
+ path: '/api/github/issue-detail',
453
+ handler: async (req, res) => {
454
+ if (req.method !== 'GET') {
455
+ sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
456
+ return;
457
+ }
458
+ try {
459
+ const u = new URL(req.url || '', 'http://127.0.0.1');
460
+ const number = Number(u.searchParams.get('number') || u.searchParams.get('issueNumber'));
461
+ const cwd = u.searchParams.get('cwd') || undefined;
462
+ const repo = u.searchParams.get('repo') || undefined;
463
+ const r = await executor.getIssueDetail(cwd, number, repo);
464
+ sendJson(res, r.ok ? 200 : 400, r);
465
+ }
466
+ catch (err) {
467
+ sendJson(res, 500, { ok: false, error: err?.message || '读取 Issue 详情异常' });
468
+ }
469
+ },
470
+ });
471
+ // 7. 本地 Git 元数据定向监听(专供右侧栏开启「本地 Git 改动实时感知」时长轮询)
320
472
  // 严格安全纪律:只针对 .git/index 与 .git/HEAD 实施定向非递归监听,严禁监听源码目录防句柄耗尽;
321
473
  // 800ms 防抖合并;连接断开时自动关闭 watcher 释放系统资源(委托给底层 git-watcher 服务)。
322
474
  bindRoute({
@@ -353,4 +505,115 @@ export function registerActionRoutes(deps, executor, cacheMap, globalCache) {
353
505
  }
354
506
  },
355
507
  });
508
+ // 8. 纯本地 Git 状态毫秒级轻量查询(专供工作区改动时的局部定向刷新)
509
+ // 严格安全与性能纪律:
510
+ // 1. 动静解耦:仅执行本地 getLocalGitStatus、getGitCommitLog 与 getGitBranches;
511
+ // 2. 100% 零网络调用、零远端 GitHub API 请求、零 Token 额度消耗,耗时仅 5~10ms;
512
+ // 3. 彻底与 PR/Issue/Run 远端数据解耦,切断因修改本地文件误触发远端全量网络风暴与超时报错的恶性循环。
513
+ bindRoute({
514
+ kind: 'exact',
515
+ path: '/api/github/local-git-status',
516
+ handler: async (req, res) => {
517
+ if (req.method !== 'GET') {
518
+ sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
519
+ return;
520
+ }
521
+ const url = new URL(req.url || '', 'http://127.0.0.1');
522
+ const cwd = url.searchParams.get('cwd');
523
+ if (!cwd || !hasGitDir(cwd)) {
524
+ sendJson(res, 400, { ok: false, error: '非 Git 仓库,无法获取本地 Git 状态' });
525
+ return;
526
+ }
527
+ try {
528
+ const gitStatus = await executor.getLocalGitStatus(cwd).catch(() => null);
529
+ const commitLog = await executor
530
+ .getGitCommitLog(cwd, gitStatus ? gitStatus.hasUpstream : false)
531
+ .catch(() => null);
532
+ const branches = await executor.getGitBranches(cwd).catch(() => null);
533
+ // 同步就地更新内存缓存中的本地元数据,保证后续全量请求也能自然拿到最新的本地状态
534
+ const cached = cacheMap.getFallback(cwd) || cacheMap.get(cwd, Date.now());
535
+ if (cached) {
536
+ if (gitStatus)
537
+ cached.gitStatus = gitStatus;
538
+ if (commitLog)
539
+ cached.commitLog = commitLog;
540
+ if (branches)
541
+ cached.branches = branches;
542
+ }
543
+ sendJson(res, 200, {
544
+ ok: true,
545
+ data: {
546
+ gitStatus,
547
+ commitLog,
548
+ branches,
549
+ },
550
+ cwd,
551
+ });
552
+ }
553
+ catch (err) {
554
+ sendJson(res, 500, { ok: false, error: err?.message || '获取本地 Git 状态失败' });
555
+ }
556
+ },
557
+ });
558
+ // 9. 单个未提交文件的 Git Diff 差异查询(供右侧栏 FileDiffModal 调用)
559
+ bindRoute({
560
+ kind: 'exact',
561
+ path: '/api/github/file-diff',
562
+ handler: async (req, res) => {
563
+ if (req.method !== 'GET') {
564
+ sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
565
+ return;
566
+ }
567
+ try {
568
+ const url = new URL(req.url || '', 'http://127.0.0.1');
569
+ const cwd = url.searchParams.get('cwd') || undefined;
570
+ const file = url.searchParams.get('file');
571
+ if (!file || !file.trim()) {
572
+ sendJson(res, 400, { ok: false, error: '缺少必需的 file 参数' });
573
+ return;
574
+ }
575
+ const resolvedCwd = executor.resolveWorkspaceCwd(cwd);
576
+ if (!hasGitDir(resolvedCwd)) {
577
+ sendJson(res, 400, { ok: false, error: '当前工作区不是 Git 仓库' });
578
+ return;
579
+ }
580
+ const diffResult = await executor.getFileDiff(resolvedCwd, file.trim());
581
+ if (!diffResult.ok) {
582
+ sendJson(res, 400, { ok: false, error: diffResult.error || '获取文件差异失败' });
583
+ return;
584
+ }
585
+ sendJson(res, 200, { ok: true, data: diffResult });
586
+ }
587
+ catch (err) {
588
+ sendJson(res, 500, { ok: false, error: err?.message || '获取文件差异失败' });
589
+ }
590
+ },
591
+ });
592
+ // 10. 本地未提交文件列表轻量流式分页查询(供右侧栏滑动加载调用)
593
+ bindRoute({
594
+ kind: 'exact',
595
+ path: '/api/github/uncommitted-files',
596
+ handler: async (req, res) => {
597
+ if (req.method !== 'GET') {
598
+ sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
599
+ return;
600
+ }
601
+ try {
602
+ const url = new URL(req.url || '', 'http://127.0.0.1');
603
+ const cwd = url.searchParams.get('cwd') || undefined;
604
+ const limit = Math.min(Math.max(1, Number(url.searchParams.get('limit')) || 15), 100);
605
+ const skip = Math.max(0, Number(url.searchParams.get('skip')) || 0);
606
+ const resolvedCwd = executor.resolveWorkspaceCwd(cwd);
607
+ if (!hasGitDir(resolvedCwd)) {
608
+ sendJson(res, 400, { ok: false, error: '当前工作区不是 Git 仓库' });
609
+ return;
610
+ }
611
+ const result = await executor.getUncommittedFiles(resolvedCwd, limit, skip);
612
+ sendJson(res, 200, { ok: true, data: result });
613
+ }
614
+ catch (err) {
615
+ sendJson(res, 500, { ok: false, error: err?.message || '获取未提交文件列表失败' });
616
+ }
617
+ },
618
+ });
356
619
  }
@@ -1,7 +1,20 @@
1
1
  import { mapLimit, tryReadWorkspaces, findWorkspacePathBySession, findMostRecentWorkspacePath, hasGitDir } from '../workspace.js';
2
2
  import { resolveIssueCategories, calculateIssueStats, basenameSafe, sendJson } from './route-helpers.js';
3
+ import { describeDeterministicError } from '../executor/error-classifier.js';
3
4
  /** 探测单个工作区时在途的 gh 进程数上限;libuv 线程池默认 4,留出余量给其他请求 */
4
5
  const WORKSPACE_PROBE_CONCURRENCY = 3;
6
+ /**
7
+ * 右侧栏单仓库 PR / Issue 的展示条数上限。
8
+ *
9
+ * 为什么从 10 提到 20:加上 `--state all` 后列表里混入了 closed/merged,
10
+ * 而 `gh pr list` 的排序依据是 **number 降序**(实测 30 条验证:14763、14752、
11
+ * 14751 …… 严格递减),不是 updatedAt。取 10 条时实测 cli/cli 只剩 5 条非 OPEN,
12
+ * 用户切到「Merged」可能只有 2-3 条,筛选体验形同虚设。
13
+ * 多拉 10 条的代价可忽略(同一个 gh 进程,仅 JSON 体积略增),
14
+ * 但绝不能改成「每个状态各查一次」——那会把 3 个 gh 子进程变成 6~9 个,
15
+ * 右侧栏首屏本就受 Promise.all 8 秒超时约束。
16
+ */
17
+ const RIGHTBAR_LIST_LIMIT = 20;
5
18
  export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, checker) {
6
19
  const { bindRoute } = deps;
7
20
  // 1. 获取全局与当前仓库的 GitHub 概览
@@ -29,6 +42,19 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
29
42
  cwd = process.cwd();
30
43
  }
31
44
  const now = Date.now();
45
+ // 先行检查当前工作区是否真实包含 .git,无 .git 铁证判定为未关联仓库
46
+ const isGitRepo = hasGitDir(cwd);
47
+ // 缓存失效判定:比对 remote 配置指纹,而不是比对解析出的仓库身份。
48
+ //
49
+ // 为什么换:身份比对把「解析器的正确性」变成了「缓存有效性的前置条件」。
50
+ // 多 remote 布局下(如 upstream 段写在 origin 段之前),取「第一个
51
+ // github.com 匹配」会拿到上游仓库,与缓存里的 fork 身份恒失配,于是
52
+ // 每次请求都作废缓存、重拉 6 个 gh 子进程。指纹只回答「配置变没变」,
53
+ // 不依赖任何语义理解,对该类偏差免疫。
54
+ //
55
+ // 指纹读取是纯本地 readFileSync(几 KB),与 hasGitDir 同级开销,零子进程。
56
+ const remoteFingerprint = isGitRepo ? executor.getRemoteFingerprint(cwd) : null;
57
+ cacheMap.invalidateIfFingerprintChanged(cwd, remoteFingerprint);
32
58
  const cachedData = cacheMap.get(cwd, now);
33
59
  if (cachedData && !force) {
34
60
  sendJson(res, 200, {
@@ -40,9 +66,12 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
40
66
  return;
41
67
  }
42
68
  const auth = await executor.checkAuth(cwd);
43
- // 先行检查当前工作区是否真实包含 .git,无 .git 铁证判定为未关联仓库
44
- const isGitRepo = hasGitDir(cwd);
45
69
  const repo = isGitRepo ? await executor.getRepoMetadata(cwd) : null;
70
+ if (repo) {
71
+ // 推导当前工作区可用的关联仓库(如 origin/Fork 与 upstream/Parent),
72
+ // 仅在存在多个关联仓库或 Fork 场景下供 UI 渲染手动切换控件。
73
+ repo.associatedRepos = executor.getAssociatedRepos(cwd, repo, auth?.user);
74
+ }
46
75
  // 若当前工作区未关联 Git 仓库,直接返回纯净空状态,坚决不拉取远端数据
47
76
  if (!isGitRepo || !repo) {
48
77
  const emptyData = {
@@ -55,35 +84,72 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
55
84
  lastUpdated: new Date().toISOString(),
56
85
  updateInfo: checker.getQuickStatus(),
57
86
  };
58
- cacheMap.set(cwd, emptyData, now);
87
+ cacheMap.set(cwd, emptyData, now, remoteFingerprint);
59
88
  sendJson(res, 200, { ok: true, data: emptyData, fromCache: false, cwd });
60
89
  return;
61
90
  }
62
91
  let pullRequests = [];
63
92
  let issues = [];
64
93
  let runs = [];
94
+ // 确定性降级说明(非错误):在 if 块内累积、在块外随响应一起落缓存,
95
+ // 因此必须在请求作用域声明,不能藏在分支里。
96
+ const deterministicNotices = [];
65
97
  // 面板增强信息载体(git 状态/Release 锚定/分支 PR——全部可选字段,
66
98
  // 进缓存与快照时旧消费方零破坏,渲染层 ?. 兜底)
67
99
  const freshExtras = {};
68
100
  if (repo && auth.loggedIn) {
69
- // 并发拉取当前文件夹仓库的 PRs、Issues 和 Runs
101
+ /*
102
+ * 并发拉取当前文件夹仓库的 PRs、Issues 和 Runs。
103
+ *
104
+ * 必须显式传 `--state all`:gh 的默认值是 open,不传就只剩未关闭记录。
105
+ * 本仓库实测 `pr list` 默认返回 []、`--state all` 才返回 [#6 CLOSED]——
106
+ * 右侧栏「只显示已打开」的根因就在这里,靠前端加筛选器救不回来,
107
+ * 因为数据在到达浏览器前就被 gh 过滤掉了。
108
+ * 与全局面板(gh search,phase29 已移除 --state=open)保持同一口径。
109
+ */
70
110
  const [prsRes, issuesRes, runsRes] = await Promise.all([
71
- executor.run(['pr', 'list', '--json', 'number,title,state,author,headRefName,baseRefName,isDraft,mergeable,reviewDecision,statusCheckRollup,url,updatedAt', '-L', '10'], { cwd, timeoutMs: 8_000 }),
72
- executor.run(['issue', 'list', '--json', 'number,title,state,author,labels,assignees,url,updatedAt', '-L', '10'], { cwd, timeoutMs: 8_000 }),
111
+ executor.run([
112
+ 'pr', 'list', '--state', 'all',
113
+ '--json', 'number,title,state,author,headRefName,baseRefName,isDraft,mergeable,reviewDecision,statusCheckRollup,url,updatedAt',
114
+ '-L', String(RIGHTBAR_LIST_LIMIT),
115
+ ], { cwd, timeoutMs: 8_000 }),
116
+ executor.run([
117
+ 'issue', 'list', '--state', 'all',
118
+ '--json', 'number,title,state,author,labels,assignees,url,updatedAt',
119
+ '-L', String(RIGHTBAR_LIST_LIMIT),
120
+ ], { cwd, timeoutMs: 8_000 }),
73
121
  executor.run(['run', 'list', '--json', 'databaseId,name,status,conclusion,event,headBranch,url,createdAt', '-L', '5'], { cwd, timeoutMs: 8_000 }),
74
122
  ]);
75
- // 核心数据拉取故障检测:若拉取核心业务数据遭遇失败(网络超时/断网/接口报错),
76
- // 绝对不能把失败伪装成「0 个 PR、0 个 Issue」并假装成功返回!
77
- const failures = [];
78
- if (!prsRes.ok)
79
- failures.push(`PR 获取失败: ${prsRes.error || '网络或接口异常'}`);
80
- if (!issuesRes.ok)
81
- failures.push(`Issue 获取失败: ${issuesRes.error || '网络或接口异常'}`);
82
- if (!runsRes.ok)
83
- failures.push(`Actions 获取失败: ${runsRes.error || '网络或接口异常'}`);
84
- if (failures.length > 0) {
85
- const failureReason = failures.join(';');
86
- // 若此前内存有缓存,保全已有缓存内容,附加 networkError 与 partialFailure
123
+ // 核心数据拉取故障检测:把失败按语义拆成两堆。
124
+ //
125
+ // 为什么必须拆:`gh` 对所有失败都返回非零退出,但两类失败的性质完全不同——
126
+ // · 确定性(仓库关了 Issues / 仓库不存在 / 无权限):重试一万次也还是失败,
127
+ // 正确处置是「该字段降级为空列表 + 一句说明」,绝不能伪装成网络故障;
128
+ // · 传输层(超时 / 断连 / 代理阻断):值得回退历史快照并允许重试。
129
+ // 旧实现把两者混为一谈,导致「仓库关了 Issues」弹出「网络连接失败」横幅,
130
+ // 还被客户端重试器当成可恢复抖动反复空烧。
131
+ const transientFailures = [];
132
+ const collectFailure = (res, label) => {
133
+ if (res.ok)
134
+ return;
135
+ const msg = res.error || '接口异常';
136
+ // errorKind 由 GhExecutor 统一分类(单一事实源),这里只做分流
137
+ if (res.errorKind === 'deterministic') {
138
+ deterministicNotices.push(`${label}: ${describeDeterministicError(msg) || msg}`);
139
+ }
140
+ else {
141
+ transientFailures.push(`${label}: ${msg}`);
142
+ }
143
+ };
144
+ collectFailure(prsRes, 'PR 获取失败');
145
+ collectFailure(issuesRes, 'Issue 获取失败');
146
+ collectFailure(runsRes, 'Actions 获取失败');
147
+ if (transientFailures.length > 0) {
148
+ const failureReason = transientFailures.join(';');
149
+ // 若此前内存有缓存,保全已有缓存内容,附加 networkError 与 partialFailure。
150
+ // repo 必须用本次新鲜解析值覆盖:列表数据旧一点可以接受,仓库身份旧了就是错的
151
+ // (典型场景:用户把 origin 从上游换成自己的 fork 后,issue list 永久失败,
152
+ // 失败分支从不写缓存,旧身份被 freeze 在 fallback 里永远出不来)。
87
153
  const fallbackCached = cacheMap.getFallback(cwd) || cacheMap.get(cwd, now);
88
154
  if (fallbackCached && (fallbackCached.pullRequests.length > 0 || fallbackCached.issues.length > 0 || fallbackCached.runs.length > 0)) {
89
155
  sendJson(res, 200, {
@@ -93,6 +159,7 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
93
159
  error: failureReason,
94
160
  data: {
95
161
  ...fallbackCached,
162
+ repo: repo || fallbackCached.repo,
96
163
  networkError: true,
97
164
  networkErrorReason: failureReason,
98
165
  auth: { ...fallbackCached.auth, networkError: true, networkErrorReason: failureReason },
@@ -165,6 +232,13 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
165
232
  freshExtras.branches = await executor.getGitBranches(cwd).catch(() => null);
166
233
  }
167
234
  const issueStats = calculateIssueStats(issues);
235
+ /*
236
+ * 截断必须可感知:`--state all` + 固定条数上限意味着历史记录可能被截掉,
237
+ * 若不透出,用户切到「Closed」看到 3 条会以为仓库只有 3 个已关闭 PR。
238
+ * 与全局面板 prsTruncated 同一口径。
239
+ */
240
+ const prsTruncated = pullRequests.length >= RIGHTBAR_LIST_LIMIT;
241
+ const issuesTruncated = issues.length >= RIGHTBAR_LIST_LIMIT;
168
242
  const freshData = {
169
243
  auth,
170
244
  repo: repo || undefined,
@@ -174,9 +248,15 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
174
248
  runs,
175
249
  lastUpdated: new Date().toISOString(),
176
250
  updateInfo: checker.getQuickStatus(),
251
+ // 确定性降级说明随数据一起落缓存:它是仓库的稳定属性(Issues 关着就一直关着),
252
+ // 每次请求重新声明没有意义,也不该让它冒泡成 networkError。
253
+ ...(deterministicNotices.length > 0 ? { notices: deterministicNotices } : {}),
254
+ // 仅在真的触顶时才下发,避免给正常场景增加无意义字段
255
+ ...(prsTruncated ? { prsTruncated: true } : {}),
256
+ ...(issuesTruncated ? { issuesTruncated: true } : {}),
177
257
  ...freshExtras,
178
258
  };
179
- cacheMap.set(cwd, freshData, now);
259
+ cacheMap.set(cwd, freshData, now, remoteFingerprint);
180
260
  sendJson(res, 200, { ok: true, data: freshData, fromCache: false, cwd });
181
261
  }
182
262
  catch (err) {
@@ -213,6 +293,10 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
213
293
  let issueStats = { total: 0, reported: 0, assigned: 0, created: 0 };
214
294
  let workspaceMatrix = [];
215
295
  let workspaceError;
296
+ /** PR 检索是否触及 limit(命中即说明可能还有更早的 PR 未返回) */
297
+ let prsTruncated = false;
298
+ // 全局驾驶舱的确定性降级说明(同单仓库口径:在分支内累积、块外落缓存)
299
+ const globalNotices = [];
216
300
  if (auth.loggedIn) {
217
301
  // 并发执行:1. 账号下仓库列表 2. 个人 PRs 3. 三维 Issue 检索(名下仓库用户提单 + 指派待办 + 我创建的)
218
302
  // 为什么三路并发搜索:
@@ -220,25 +304,47 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
220
304
  // - --assignee=@me: 捕获团队成员或自己指派给当前账号的待办任务 (含跨组织/第三方项目)
221
305
  // - --author=@me: 捕获当前账号亲自发起的任务与向外部提起的议题
222
306
  // 3 个轻量进程与 repo/prs 同步 spawn,经 OS 并发调度实测仅耗时 3 秒出头,完全满足 8 秒超时门限。
223
- const issueFields = 'number,title,repository,author,assignees,labels,url,updatedAt';
307
+ // 为什么不再限定 --state=open:
308
+ // 实测当前账号 7 个 PR 全为 merged/closed、5 个 Issue 全为 closed,
309
+ // `--state=open` 恒返回空数组(退出码仍为 0,属于"正确但无用")。
310
+ // 用户看到的就是一片空白,且无法区分「确实没有」与「请求失败」。
311
+ // 改为拉全量并在前端做状态筛选:数据先可见,再谈收敛。
312
+ // 同时 --json 必须补上 state 字段——不下发的话前端根本无从筛选。
313
+ const issueFields = 'number,title,state,repository,author,assignees,labels,url,updatedAt';
314
+ /**
315
+ * PR 检索上限。提到常量是因为「命中 limit」这个事实要被下游用来判断截断,
316
+ * 散落的字面量会让两处逻辑(查询与判断)出现漂移。
317
+ */
318
+ const PR_SEARCH_LIMIT = 30;
224
319
  const queryResults = await Promise.allSettled([
225
320
  executor.run(['repo', 'list', '--limit', '20', '--json', 'name,nameWithOwner,description,defaultBranchRef,isPrivate,stargazerCount,updatedAt,url'], { timeoutMs: 8_000 }),
226
- executor.run(['search', 'prs', '--author=@me', '--state=open', '--limit', '10', '--json', 'number,title,repository,url,updatedAt,isDraft'], { timeoutMs: 8_000 }),
227
- executor.run(['search', 'issues', '--owner=@me', '--state=open', '--sort=updated', '--limit', '15', '--json', issueFields], { timeoutMs: 8_000 }),
228
- executor.run(['search', 'issues', '--assignee=@me', '--state=open', '--sort=updated', '--limit', '15', '--json', issueFields], { timeoutMs: 8_000 }),
229
- executor.run(['search', 'issues', '--author=@me', '--state=open', '--sort=updated', '--limit', '15', '--json', issueFields], { timeoutMs: 8_000 }),
321
+ executor.run(['search', 'prs', '--author=@me', '--limit', String(PR_SEARCH_LIMIT), '--json', 'number,title,state,repository,url,updatedAt,isDraft'], { timeoutMs: 8_000 }),
322
+ executor.run(['search', 'issues', '--owner=@me', '--sort=updated', '--limit', '25', '--json', issueFields], { timeoutMs: 8_000 }),
323
+ executor.run(['search', 'issues', '--assignee=@me', '--sort=updated', '--limit', '25', '--json', issueFields], { timeoutMs: 8_000 }),
324
+ executor.run(['search', 'issues', '--author=@me', '--sort=updated', '--limit', '25', '--json', issueFields], { timeoutMs: 8_000 }),
230
325
  ]);
231
326
  const reposRes = queryResults[0].status === 'fulfilled' ? queryResults[0].value : { ok: false };
232
327
  const prsRes = queryResults[1].status === 'fulfilled' ? queryResults[1].value : { ok: false };
233
328
  const ownerIssuesRes = queryResults[2].status === 'fulfilled' ? queryResults[2].value : { ok: false };
234
329
  const assignedIssuesRes = queryResults[3].status === 'fulfilled' ? queryResults[3].value : { ok: false };
235
330
  const authorIssuesRes = queryResults[4].status === 'fulfilled' ? queryResults[4].value : { ok: false };
236
- // 检测全局拉取是否发生网络故障,绝不把网络失败当作「0 仓库、0 PR」伪造成功
331
+ // 检测全局拉取是否发生网络故障,绝不把网络失败当作「0 仓库、0 PR」伪造成功。
332
+ // 与单仓库概览同一口径:确定性失败(账号级搜索本就不存在确定性场景,
333
+ // 但仓库列表拉取可能命中 404/403)不进入网络故障分支,只作为 notice 透出。
237
334
  const globalFailures = [];
238
- if (!reposRes.ok)
239
- globalFailures.push(`仓库列表拉取失败: ${reposRes.error || '网络或接口异常'}`);
240
- if (!prsRes.ok)
241
- globalFailures.push(`个人 PR 拉取失败: ${prsRes.error || '网络或接口异常'}`);
335
+ const collectGlobalFailure = (res, label) => {
336
+ if (res.ok)
337
+ return;
338
+ const msg = res.error || '接口异常';
339
+ if (res.errorKind === 'deterministic') {
340
+ globalNotices.push(`${label}: ${describeDeterministicError(msg) || msg}`);
341
+ }
342
+ else {
343
+ globalFailures.push(`${label}: ${msg}`);
344
+ }
345
+ };
346
+ collectGlobalFailure(reposRes, '仓库列表拉取失败');
347
+ collectGlobalFailure(prsRes, '个人 PR 拉取失败');
242
348
  if (globalFailures.length > 0) {
243
349
  const globalNetReason = globalFailures.join(';');
244
350
  const fallbackGlobal = globalCache.getFallback('global') || globalCache.get('global', now);
@@ -272,6 +378,15 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
272
378
  }
273
379
  if (prsRes.ok && Array.isArray(prsRes.data)) {
274
380
  myPrs = prsRes.data;
381
+ /*
382
+ * 命中 limit 说明可能还有更早的 PR 未返回。
383
+ * 去掉 --state=open 后列表按 updatedAt 降序返回「最近 30 条」,
384
+ * 若账号历史 PR 超过 30 条,界面会静默少显示——
385
+ * 用户无从知道「列表到此为止」还是「数据丢了」。
386
+ * 必须把截断事实透出到 UI。
387
+ */
388
+ if (myPrs.length >= PR_SEARCH_LIMIT)
389
+ prsTruncated = true;
275
390
  }
276
391
  // 多源 Issues 聚合、按 URL 唯一去重并排序
277
392
  const issueMap = new Map();
@@ -343,6 +458,9 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
343
458
  lastUpdated: new Date().toISOString(),
344
459
  updateInfo: checker.getQuickStatus(),
345
460
  ...(workspaceError ? { workspaceError } : {}),
461
+ // 仅在真的触顶时才下发,避免给正常场景增加无意义字段
462
+ ...(prsTruncated ? { prsTruncated: true } : {}),
463
+ ...(globalNotices.length > 0 ? { notices: globalNotices } : {}),
346
464
  };
347
465
  globalCache.set('global', globalData, now);
348
466
  sendJson(res, 200, { ok: true, data: globalData, fromCache: false });