dsh-github-flow 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.en.md +98 -15
  2. package/README.md +101 -15
  3. package/docs/UPGRADE-NOTES.md +12 -11
  4. package/docs/architecture.md +56 -13
  5. package/lib/api/route-helpers.d.ts +19 -0
  6. package/lib/api/route-helpers.js +54 -0
  7. package/lib/api/routes-actions.js +75 -1
  8. package/lib/api/routes-lifecycle.js +8 -1
  9. package/lib/api/routes-overview.d.ts +26 -0
  10. package/lib/api/routes-overview.js +276 -34
  11. package/lib/api/routes-proxy.js +8 -1
  12. package/lib/api/routes.d.ts +11 -1
  13. package/lib/api/routes.js +15 -2
  14. package/lib/cache.d.ts +0 -21
  15. package/lib/cache.js +0 -37
  16. package/lib/client.d.ts +2 -1
  17. package/lib/client.js +1585 -429
  18. package/lib/destructive.d.ts +59 -0
  19. package/lib/destructive.js +285 -0
  20. package/lib/executor/args-guard.d.ts +17 -0
  21. package/lib/executor/args-guard.js +23 -0
  22. package/lib/executor/failure-anchors.d.ts +52 -0
  23. package/lib/executor/failure-anchors.js +116 -0
  24. package/lib/executor/pr-review.d.ts +95 -0
  25. package/lib/executor/pr-review.js +175 -0
  26. package/lib/executor/run-log.d.ts +36 -0
  27. package/lib/executor/run-log.js +78 -0
  28. package/lib/executor.d.ts +156 -3
  29. package/lib/executor.js +594 -28
  30. package/lib/github-service.d.ts +1 -1
  31. package/lib/main.d.ts +92 -18
  32. package/lib/main.js +186 -95
  33. package/lib/renderers/common.d.ts +0 -3
  34. package/lib/renderers/common.js +8 -44
  35. package/lib/renderers/markdown.d.ts +1 -0
  36. package/lib/renderers/markdown.js +1 -0
  37. package/lib/renderers/pr.js +26 -1
  38. package/lib/renderers/task.d.ts +9 -0
  39. package/lib/renderers/task.js +120 -0
  40. package/lib/review-rules.d.ts +60 -0
  41. package/lib/review-rules.js +204 -0
  42. package/lib/shared/state-badges.d.ts +42 -0
  43. package/lib/shared/state-badges.js +102 -0
  44. package/lib/task-primitives.d.ts +109 -0
  45. package/lib/task-primitives.js +194 -0
  46. package/lib/tool-tiers.d.ts +57 -0
  47. package/lib/tool-tiers.js +168 -0
  48. package/lib/tools/agent-task.js +4 -4
  49. package/lib/tools/api.js +18 -6
  50. package/lib/tools/cache.js +42 -40
  51. package/lib/tools/codespace.js +4 -12
  52. package/lib/tools/discussion.js +4 -4
  53. package/lib/tools/gist.d.ts +1 -39
  54. package/lib/tools/gist.js +4 -14
  55. package/lib/tools/help.js +52 -13
  56. package/lib/tools/issue.js +16 -28
  57. package/lib/tools/label.js +4 -15
  58. package/lib/tools/pr.d.ts +47 -0
  59. package/lib/tools/pr.js +170 -33
  60. package/lib/tools/project.js +4 -13
  61. package/lib/tools/release.js +11 -22
  62. package/lib/tools/repo.js +4 -4
  63. package/lib/tools/ruleset.js +9 -6
  64. package/lib/tools/run.js +76 -24
  65. package/lib/tools/secret.js +11 -17
  66. package/lib/tools/skill.js +18 -22
  67. package/lib/tools/status.js +4 -4
  68. package/lib/tools/task.d.ts +219 -0
  69. package/lib/tools/task.js +457 -0
  70. package/lib/types.d.ts +183 -1
  71. package/lib/ui/help-modal.d.ts +1 -1
  72. package/lib/ui/help-modal.js +20 -10
  73. package/lib/ui/issue-modal.js +7 -3
  74. package/lib/ui/pr-modal.js +50 -11
  75. package/lib/ui/run-modal.d.ts +26 -0
  76. package/lib/ui/run-modal.js +486 -0
  77. package/lib/ui/styles.d.ts +1 -1
  78. package/lib/ui/styles.js +370 -44
  79. package/lib/ui/sync-settings-modal.js +1 -1
  80. package/lib/ui/workspace-panel.js +78 -23
  81. package/lib/version.d.ts +1 -1
  82. package/lib/version.js +1 -1
  83. package/package.json +9 -2
@@ -14,7 +14,44 @@ const WORKSPACE_PROBE_CONCURRENCY = 3;
14
14
  * 但绝不能改成「每个状态各查一次」——那会把 3 个 gh 子进程变成 6~9 个,
15
15
  * 右侧栏首屏本就受 Promise.all 8 秒超时约束。
16
16
  */
17
- const RIGHTBAR_LIST_LIMIT = 20;
17
+ /**
18
+ * 列表默认每页条数。
19
+ *
20
+ * 为什么是 15 而不是 20:与右侧栏「Git 提交」「未提交变更」两个已有分页的默认页大小对齐,
21
+ * 用户下滑即可继续加载(`prLimit/issueLimit/runLimit` 查询参数),所以首屏不必再靠
22
+ * 「多拉一点」来换取筛选体验。20 条时代首屏 JSON 实测可达 28,608 字符,直接撞上
23
+ * executor 的 24,000 字符输出上限 → 被截断 → JSON 解析被跳过 → 面板空列表且无报错。
24
+ */
25
+ export const RIGHTBAR_LIST_LIMIT = 15;
26
+ /** 单页上限:防止一次请求把几万条拉进内存(gh 自身也没有更细的分页) */
27
+ const LIST_LIMIT_MAX = 100;
28
+ /**
29
+ * 列表类查询的输出上限(字符)。
30
+ *
31
+ * 为什么不能沿用默认的 24,000:那个阈值是按「喂给模型上下文」调的。面板列表是
32
+ * **程序化消费**——前端只渲染首屏,永不整段进模型上下文。用 24KB 截 15 条 PR 的结果是
33
+ * 截断后 JSON 解析被跳过、`data` 变 undefined,表现为「这个仓库拉不到 PR」。
34
+ * 与 pr diff / issue 详情放宽到 2MB 是同一个理由。
35
+ */
36
+ const LIST_OUTPUT_CAP = 1_048_576;
37
+ /**
38
+ * 解析分页参数:非法/缺失回落默认页大小,超过上限则截到上限。
39
+ * 上限存在是因为面板可能被脚本按 `prLimit=999999` 调用。
40
+ */
41
+ function parseListLimit(raw) {
42
+ const n = Number(raw);
43
+ if (!Number.isFinite(n) || n <= 0)
44
+ return RIGHTBAR_LIST_LIMIT;
45
+ return Math.min(Math.floor(n), LIST_LIMIT_MAX);
46
+ }
47
+ /**
48
+ * 缓存键:必须把三个分页参数算进去。
49
+ * 只用 cwd 当键的话,「先加载 15 条」的缓存会喂给「请求 30 条」的那次刷新,
50
+ * 表现是点了「加载更多」列表不变——而且因为命中的是缓存,连请求日志都没有。
51
+ */
52
+ export function overviewCacheKey(cwd, prLimit, issueLimit, runLimit) {
53
+ return `${cwd}|${prLimit}|${issueLimit}|${runLimit}`;
54
+ }
18
55
  export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, checker) {
19
56
  const { bindRoute } = deps;
20
57
  // 1. 获取全局与当前仓库的 GitHub 概览
@@ -31,6 +68,10 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
31
68
  let cwd = url.searchParams.get('cwd');
32
69
  const sessionId = url.searchParams.get('sessionId');
33
70
  const force = url.searchParams.get('force') === 'true';
71
+ // 分页参数:默认 15,非法值回落默认,超过上限截断(见 parseListLimit)
72
+ const prLimit = parseListLimit(url.searchParams.get('prLimit'));
73
+ const issueLimit = parseListLimit(url.searchParams.get('issueLimit'));
74
+ const runLimit = parseListLimit(url.searchParams.get('runLimit'));
34
75
  // 会话 ID → 物理路径,以及「最近活跃工作区」兜底,统一由 workspace 模块提供
35
76
  if (!cwd && sessionId) {
36
77
  cwd = findWorkspacePathBySession(sessionId) || null;
@@ -54,8 +95,12 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
54
95
  //
55
96
  // 指纹读取是纯本地 readFileSync(几 KB),与 hasGitDir 同级开销,零子进程。
56
97
  const remoteFingerprint = isGitRepo ? executor.getRemoteFingerprint(cwd) : null;
57
- cacheMap.invalidateIfFingerprintChanged(cwd, remoteFingerprint);
58
- const cachedData = cacheMap.get(cwd, now);
98
+ // 缓存键带上分页参数:否则「15 条」的缓存会喂给「请求 30 条」的刷新,
99
+ // 表现为点了加载更多但列表不变(且连请求日志都没有,因为命中的是缓存)。
100
+ const cacheKey = overviewCacheKey(cwd, prLimit, issueLimit, runLimit);
101
+ // 指纹失效必须针对**同一个键**,否则「remote url 变了」永远作废不掉缓存条目
102
+ cacheMap.invalidateIfFingerprintChanged(cacheKey, remoteFingerprint);
103
+ const cachedData = cacheMap.get(cacheKey, now);
59
104
  if (cachedData && !force) {
60
105
  sendJson(res, 200, {
61
106
  ok: true,
@@ -84,11 +129,13 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
84
129
  lastUpdated: new Date().toISOString(),
85
130
  updateInfo: checker.getQuickStatus(),
86
131
  };
87
- cacheMap.set(cwd, emptyData, now, remoteFingerprint);
132
+ cacheMap.set(cacheKey, emptyData, now, remoteFingerprint);
88
133
  sendJson(res, 200, { ok: true, data: emptyData, fromCache: false, cwd });
89
134
  return;
90
135
  }
91
136
  let pullRequests = [];
137
+ /** 仓库总量(GraphQL 统计;失败为 null,前端回落已加载条数) */
138
+ let repoCounts = null;
92
139
  let issues = [];
93
140
  let runs = [];
94
141
  // 确定性降级说明(非错误):在 if 块内累积、在块外随响应一起落缓存,
@@ -107,18 +154,70 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
107
154
  * 因为数据在到达浏览器前就被 gh 过滤掉了。
108
155
  * 与全局面板(gh search,phase29 已移除 --state=open)保持同一口径。
109
156
  */
110
- const [prsRes, issuesRes, runsRes] = await Promise.all([
157
+ // 仓库标识(owner/name)。只接受该形态,避免把任意字符串拼进 GraphQL / REST 路径。
158
+ const repoNwo = repo?.nameWithOwner || '';
159
+ const repoNwoValid = /^[\w.-]+\/[\w.-]+$/.test(repoNwo);
160
+ const [prsRes, issuesRes, runsRes, totalsRes, runsTotalRes] = await Promise.all([
111
161
  executor.run([
112
162
  '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 }),
163
+ // 列表只渲染简要信息(编号、标题、分支、状态、合并冲突与 CI),无需拉取 author 等未消费对象
164
+ '--json', 'number,title,state,headRefName,baseRefName,isDraft,mergeable,reviewDecision,statusCheckRollup,url,updatedAt',
165
+ '-L', String(prLimit),
166
+ ], { cwd, timeoutMs: 8_000, maxOutputChars: LIST_OUTPUT_CAP, maxOutputBytes: LIST_OUTPUT_CAP * 4 }),
116
167
  executor.run([
117
168
  'issue', 'list', '--state', 'all',
118
169
  '--json', 'number,title,state,author,labels,assignees,url,updatedAt',
119
- '-L', String(RIGHTBAR_LIST_LIMIT),
120
- ], { cwd, timeoutMs: 8_000 }),
121
- executor.run(['run', 'list', '--json', 'databaseId,name,status,conclusion,event,headBranch,url,createdAt', '-L', '5'], { cwd, timeoutMs: 8_000 }),
170
+ '-L', String(issueLimit),
171
+ ], { cwd, timeoutMs: 8_000, maxOutputChars: LIST_OUTPUT_CAP, maxOutputBytes: LIST_OUTPUT_CAP * 4 }),
172
+ executor.run(['run', 'list', '--json', 'databaseId,name,status,conclusion,event,headBranch,url,createdAt', '-L', String(runLimit)], { cwd, timeoutMs: 8_000, maxOutputChars: LIST_OUTPUT_CAP, maxOutputBytes: LIST_OUTPUT_CAP * 4 }),
173
+ /*
174
+ * 总量统计(与列表分页彻底解耦)。
175
+ *
176
+ * 为什么必须单独查:列表是「按页拉」的,`pullRequests.length` 只是**已加载条数**,
177
+ * 拿它当总数会在用户下滑时从 15 跳到 30、再跳到 45——看起来像总数在变,是明显的 bug。
178
+ * 真正的总数只能靠 GitHub 的计数接口拿。
179
+ *
180
+ * 为什么用 GraphQL `search.issueCount` 而不是 `repository.pullRequests.totalCount`:
181
+ * `Repository.issues` 在 GitHub 语义里**包含 PR**(共用 issue 空间),直接取会把 PR 算进 Issue;
182
+ * `search` 加 `is:pr` / `is:issue` 限定符才能精确区分,且一次请求可以并发拿全 7 个口径。
183
+ */
184
+ (() => {
185
+ const nwo = repoNwo;
186
+ // 只接受 owner/name 形态,避免把任意字符串拼进 GraphQL 查询
187
+ if (!repoNwoValid)
188
+ return Promise.resolve({ ok: false });
189
+ const scope = `repo:${nwo}`;
190
+ // GraphQL 字符串字面量必须用双引号;这里整段 query 作为**单个 argv 元素**传给
191
+ // execFile(不经过 shell),所以双引号无需再转义,写了反斜杠反而会让 GitHub 解析失败。
192
+ // 注意只产出「带引号的查询值」,`query:` 前缀由调用点写——否则会拼成 query:query:"..."
193
+ const q = (extra) => `"${scope} ${extra}"`;
194
+ const query = `query{` +
195
+ `prAll:search(query:${q('is:pr')},type:ISSUE){issueCount}` +
196
+ `prOpen:search(query:${q('is:pr is:open')},type:ISSUE){issueCount}` +
197
+ `prMerged:search(query:${q('is:pr is:merged')},type:ISSUE){issueCount}` +
198
+ `prClosed:search(query:${q('is:pr is:closed')},type:ISSUE){issueCount}` +
199
+ `issAll:search(query:${q('is:issue')},type:ISSUE){issueCount}` +
200
+ `issOpen:search(query:${q('is:issue is:open')},type:ISSUE){issueCount}` +
201
+ `issClosed:search(query:${q('is:issue is:closed')},type:ISSUE){issueCount}` +
202
+ `}`;
203
+ return executor.run(['api', 'graphql', '-f', `query=${query}`], { cwd, timeoutMs: 8_000 });
204
+ })(),
205
+ /*
206
+ * Actions 运行总数。
207
+ *
208
+ * 为什么单独走 REST:GitHub GraphQL **没有** workflow runs 连接
209
+ * (`Repository` 上不存在 `workflowRuns` 字段),拿不到 totalCount;
210
+ * REST `GET /repos/{owner}/{repo}/actions/runs?per_page=1` 的响应体里带
211
+ * `total_count`(实测该仓 1146),per_page=1 只为把响应压到最小。
212
+ */
213
+ (() => {
214
+ if (!repoNwoValid)
215
+ return Promise.resolve({ ok: false });
216
+ return executor.run(['api', `repos/${repoNwo}/actions/runs?per_page=1`], {
217
+ cwd,
218
+ timeoutMs: 8_000,
219
+ });
220
+ })(),
122
221
  ]);
123
222
  // 核心数据拉取故障检测:把失败按语义拆成两堆。
124
223
  //
@@ -130,8 +229,15 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
130
229
  // 还被客户端重试器当成可恢复抖动反复空烧。
131
230
  const transientFailures = [];
132
231
  const collectFailure = (res, label) => {
133
- if (res.ok)
232
+ if (res.ok) {
233
+ // 截断不是成功。`executor.run` 在超限时会跳过 JSON.parse(data 变 undefined)
234
+ // 但仍返回 ok:true —— 旧实现因此把它当「仓库没有 PR」,面板显示空列表且零报错。
235
+ // 这条曾经让一个 579 个 PR 的仓库看起来「无法拉取 PR」,必须显式透出。
236
+ if (res.truncated) {
237
+ deterministicNotices.push(`${label}: 返回数据过大已被截断,请到 GitHub 查看完整列表`);
238
+ }
134
239
  return;
240
+ }
135
241
  const msg = res.error || '接口异常';
136
242
  // errorKind 由 GhExecutor 统一分类(单一事实源),这里只做分流
137
243
  if (res.errorKind === 'deterministic') {
@@ -150,7 +256,7 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
150
256
  // repo 必须用本次新鲜解析值覆盖:列表数据旧一点可以接受,仓库身份旧了就是错的
151
257
  // (典型场景:用户把 origin 从上游换成自己的 fork 后,issue list 永久失败,
152
258
  // 失败分支从不写缓存,旧身份被 freeze 在 fallback 里永远出不来)。
153
- const fallbackCached = cacheMap.getFallback(cwd) || cacheMap.get(cwd, now);
259
+ const fallbackCached = cacheMap.getFallback(cacheKey) || cacheMap.get(cacheKey, now);
154
260
  if (fallbackCached && (fallbackCached.pullRequests.length > 0 || fallbackCached.issues.length > 0 || fallbackCached.runs.length > 0)) {
155
261
  sendJson(res, 200, {
156
262
  ok: true,
@@ -230,15 +336,66 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
230
336
  .catch(() => null);
231
337
  // 5. 分支列表(纯本地毫秒级):供右侧栏分支选择器 0ms 秒开,消除独立请求延迟与 404 风险
232
338
  freshExtras.branches = await executor.getGitBranches(cwd).catch(() => null);
339
+ /*
340
+ * 解析「仓库总量」。
341
+ *
342
+ * 与列表分页彻底解耦:列表按页拉,`pullRequests.length` 只是已加载条数;
343
+ * 总数来自 GraphQL `search.issueCount`(第 4 路)与 REST `actions/runs.total_count`(第 5 路)。
344
+ * 任何解析失败都返回 null → 前端回落到「已加载条数」,绝不因计数失败影响列表本身。
345
+ */
346
+ repoCounts = (() => {
347
+ const body = totalsRes?.ok ? totalsRes.data : null;
348
+ const root = body && typeof body === 'object' ? body.data : null;
349
+ if (!root || typeof root !== 'object')
350
+ return null;
351
+ const num = (key) => {
352
+ const v = root[key];
353
+ return v && typeof v.issueCount === 'number' ? v.issueCount : undefined;
354
+ };
355
+ // Actions 总数来自 REST(GraphQL 没有 workflow runs 连接),响应体里是 total_count
356
+ const runsBody = runsTotalRes?.ok ? runsTotalRes.data : null;
357
+ const runTotal = runsBody && typeof runsBody.total_count === 'number' ? runsBody.total_count : undefined;
358
+ const prTotal = num('prAll');
359
+ // PR/Issue 统计失败但 Actions 拿到时,仍要下发 runTotal(两者互相独立)
360
+ if (prTotal === undefined)
361
+ return runTotal === undefined ? null : { runTotal };
362
+ return {
363
+ prTotal,
364
+ prOpenTotal: num('prOpen'),
365
+ prMergedTotal: num('prMerged'),
366
+ prClosedTotal: num('prClosed'),
367
+ issueTotal: num('issAll'),
368
+ issueOpenTotal: num('issOpen'),
369
+ issueClosedTotal: num('issClosed'),
370
+ ...(runTotal !== undefined ? { runTotal } : {}),
371
+ };
372
+ })();
233
373
  }
234
374
  const issueStats = calculateIssueStats(issues);
235
375
  /*
236
- * 截断必须可感知:`--state all` + 固定条数上限意味着历史记录可能被截掉,
237
- * 若不透出,用户切到「Closed」看到 3 条会以为仓库只有 3 个已关闭 PR。
238
- * 与全局面板 prsTruncated 同一口径。
376
+ * 「还有没有更多」判定。
377
+ *
378
+ * 有真实总数时用精确比较(loaded < total)——这是「还有更多」的定义本身;
379
+ * 拿不到总数才回落到「本次是否拉满」的启发式。
380
+ *
381
+ * 为什么必须精确:`>= prLimit` 在边界上会误报。例如 loaded=total 时,
382
+ * `loaded >= prLimit` 仍为真,页脚一直显示,用户点下去却一条都拉不到。
383
+ *
384
+ * 为什么还要叠加 `!*Saturated`:`parseListLimit` 把请求夹在 LIST_LIMIT_MAX(100) 内。
385
+ * 一旦客户端已经把 limit 顶到这个上限,再点「加载更多」只会拿到同样的 100 条
386
+ * (limit 115 → 夹回 100),而 loaded(100) < total(323) 又恒成立 →
387
+ * 页脚永远显示、永远点不动,这是比误报更糟的死循环。
388
+ * 此时必须停止下发 truncated,由前端改显「已加载 X / 共 Y,更早记录请到 GitHub」。
239
389
  */
240
- const prsTruncated = pullRequests.length >= RIGHTBAR_LIST_LIMIT;
241
- const issuesTruncated = issues.length >= RIGHTBAR_LIST_LIMIT;
390
+ const prLimitSaturated = prLimit >= LIST_LIMIT_MAX;
391
+ const issueLimitSaturated = issueLimit >= LIST_LIMIT_MAX;
392
+ const runLimitSaturated = runLimit >= LIST_LIMIT_MAX;
393
+ const prsTruncated = !prLimitSaturated &&
394
+ (repoCounts?.prTotal !== undefined ? pullRequests.length < repoCounts.prTotal : pullRequests.length >= prLimit);
395
+ const issuesTruncated = !issueLimitSaturated &&
396
+ (repoCounts?.issueTotal !== undefined ? issues.length < repoCounts.issueTotal : issues.length >= issueLimit);
397
+ const runsTruncated = !runLimitSaturated &&
398
+ (repoCounts?.runTotal !== undefined ? runs.length < repoCounts.runTotal : runs.length >= runLimit);
242
399
  const freshData = {
243
400
  auth,
244
401
  repo: repo || undefined,
@@ -254,9 +411,12 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
254
411
  // 仅在真的触顶时才下发,避免给正常场景增加无意义字段
255
412
  ...(prsTruncated ? { prsTruncated: true } : {}),
256
413
  ...(issuesTruncated ? { issuesTruncated: true } : {}),
414
+ ...(runsTruncated ? { runsTruncated: true } : {}),
415
+ // 仓库总量(与分页无关的真实总数);查询失败时整组缺席,前端回落已加载条数
416
+ ...(repoCounts || {}),
257
417
  ...freshExtras,
258
418
  };
259
- cacheMap.set(cwd, freshData, now, remoteFingerprint);
419
+ cacheMap.set(cacheKey, freshData, now, remoteFingerprint);
260
420
  sendJson(res, 200, { ok: true, data: freshData, fromCache: false, cwd });
261
421
  }
262
422
  catch (err) {
@@ -276,7 +436,13 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
276
436
  // 全局驾驶舱要 spawn 十几次 gh(认证 + 仓库 + PR + Issue + 每个工作区探测),
277
437
  // 实测冷启动曾达 18.9 秒。缓存是这里唯一的解药:面板每次挂载都会来取一次。
278
438
  const now = Date.now();
279
- const cachedGlobal = globalCache.get('global', now);
439
+ // 全局面板同样支持分页:默认 15,与右侧栏以及 Git/未提交两条列表同口径。
440
+ // 缓存键必须带分页参数,否则「15 条」的缓存会喂给「请求 30 条」的刷新。
441
+ const url = new URL(req.url || '', 'http://127.0.0.1');
442
+ const prLimit = parseListLimit(url.searchParams.get('prLimit'));
443
+ const issueLimit = parseListLimit(url.searchParams.get('issueLimit'));
444
+ const globalCacheKey = `global|${prLimit}|${issueLimit}`;
445
+ const cachedGlobal = globalCache.get(globalCacheKey, now);
280
446
  if (cachedGlobal) {
281
447
  sendJson(res, 200, {
282
448
  ok: true,
@@ -289,12 +455,16 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
289
455
  const auth = await executor.checkAuth();
290
456
  let userRepos = [];
291
457
  let myPrs = [];
458
+ /** 账号级总量(GraphQL 统计;失败为 null) */
459
+ let accountCounts = null;
292
460
  let myIssues = [];
293
461
  let issueStats = { total: 0, reported: 0, assigned: 0, created: 0 };
294
462
  let workspaceMatrix = [];
295
463
  let workspaceError;
296
464
  /** PR 检索是否触及 limit(命中即说明可能还有更早的 PR 未返回) */
297
465
  let prsTruncated = false;
466
+ /** Issues 三路检索是否任一路触及 limit(去重合并后无法从长度反推) */
467
+ let issuesTruncated = false;
298
468
  // 全局驾驶舱的确定性降级说明(同单仓库口径:在分支内累积、块外落缓存)
299
469
  const globalNotices = [];
300
470
  if (auth.loggedIn) {
@@ -315,19 +485,64 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
315
485
  * PR 检索上限。提到常量是因为「命中 limit」这个事实要被下游用来判断截断,
316
486
  * 散落的字面量会让两处逻辑(查询与判断)出现漂移。
317
487
  */
318
- const PR_SEARCH_LIMIT = 30;
319
488
  const queryResults = await Promise.allSettled([
320
489
  executor.run(['repo', 'list', '--limit', '20', '--json', 'name,nameWithOwner,description,defaultBranchRef,isPrivate,stargazerCount,updatedAt,url'], { 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 }),
490
+ executor.run(['search', 'prs', '--author=@me', '--limit', String(prLimit), '--json', 'number,title,state,repository,url,updatedAt,isDraft'], { timeoutMs: 8_000 }),
491
+ executor.run(['search', 'issues', '--owner=@me', '--sort=updated', '--limit', String(issueLimit), '--json', issueFields], { timeoutMs: 8_000 }),
492
+ executor.run(['search', 'issues', '--assignee=@me', '--sort=updated', '--limit', String(issueLimit), '--json', issueFields], { timeoutMs: 8_000 }),
493
+ executor.run(['search', 'issues', '--author=@me', '--sort=updated', '--limit', String(issueLimit), '--json', issueFields], { timeoutMs: 8_000 }),
494
+ /*
495
+ * 账号级 PR 总量(与列表分页解耦)。
496
+ *
497
+ * 为什么必须单独查:`myPrs.length` 只是已加载条数,拿它当总数会在下滑时不断变大。
498
+ * PR 侧口径与列表完全一致(都是 `is:pr author:@me`),所以这个总数可以精确比较。
499
+ *
500
+ * 为什么**不**给 Issue 也配一个总数:列表是 owner/assignee/author 三路的并集,
501
+ * 而唯一能给出不重复总数的查询是 `is:issue involves:@me`——它不含「我仓库里
502
+ * 别人提的 issue」(实测差集确实存在,不是索引延迟)。拿它当总数会**小于**已加载条数,
503
+ * 三路分别计数相加又会因重叠而虚高。既然拿不到正确口径,就不下发假总数,
504
+ * 由前端老实显示「已加载 N」。
505
+ */
506
+ executor.run([
507
+ 'api', 'graphql', '-f',
508
+ 'query=' +
509
+ `query{` +
510
+ `prAll:search(query:"is:pr author:@me",type:ISSUE){issueCount}` +
511
+ `prOpen:search(query:"is:pr author:@me is:open",type:ISSUE){issueCount}` +
512
+ `prMerged:search(query:"is:pr author:@me is:merged",type:ISSUE){issueCount}` +
513
+ `prClosed:search(query:"is:pr author:@me is:closed",type:ISSUE){issueCount}` +
514
+ `}`,
515
+ ], { timeoutMs: 10_000 }),
325
516
  ]);
326
517
  const reposRes = queryResults[0].status === 'fulfilled' ? queryResults[0].value : { ok: false };
327
518
  const prsRes = queryResults[1].status === 'fulfilled' ? queryResults[1].value : { ok: false };
328
519
  const ownerIssuesRes = queryResults[2].status === 'fulfilled' ? queryResults[2].value : { ok: false };
329
520
  const assignedIssuesRes = queryResults[3].status === 'fulfilled' ? queryResults[3].value : { ok: false };
330
521
  const authorIssuesRes = queryResults[4].status === 'fulfilled' ? queryResults[4].value : { ok: false };
522
+ const totalsRes = queryResults[5].status === 'fulfilled' ? queryResults[5].value : { ok: false };
523
+ /*
524
+ * 解析账号级总量。失败时返回 null → 前端回落「已加载条数」,
525
+ * 绝不因计数失败影响列表本身。
526
+ */
527
+ accountCounts = (() => {
528
+ const body = totalsRes?.ok ? totalsRes.data : null;
529
+ const root = body && typeof body === 'object' ? body.data : null;
530
+ if (!root || typeof root !== 'object')
531
+ return null;
532
+ const num = (key) => {
533
+ const v = root[key];
534
+ return v && typeof v.issueCount === 'number' ? v.issueCount : undefined;
535
+ };
536
+ const prTotal = num('prAll');
537
+ if (prTotal === undefined)
538
+ return null;
539
+ return {
540
+ prTotal,
541
+ prOpenTotal: num('prOpen'),
542
+ prMergedTotal: num('prMerged'),
543
+ prClosedTotal: num('prClosed'),
544
+ };
545
+ })();
331
546
  // 检测全局拉取是否发生网络故障,绝不把网络失败当作「0 仓库、0 PR」伪造成功。
332
547
  // 与单仓库概览同一口径:确定性失败(账号级搜索本就不存在确定性场景,
333
548
  // 但仓库列表拉取可能命中 404/403)不进入网络故障分支,只作为 notice 透出。
@@ -347,7 +562,7 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
347
562
  collectGlobalFailure(prsRes, '个人 PR 拉取失败');
348
563
  if (globalFailures.length > 0) {
349
564
  const globalNetReason = globalFailures.join(';');
350
- const fallbackGlobal = globalCache.getFallback('global') || globalCache.get('global', now);
565
+ const fallbackGlobal = globalCache.getFallback(globalCacheKey) || globalCache.get(globalCacheKey, now);
351
566
  if (fallbackGlobal) {
352
567
  sendJson(res, 200, {
353
568
  ok: true,
@@ -379,14 +594,20 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
379
594
  if (prsRes.ok && Array.isArray(prsRes.data)) {
380
595
  myPrs = prsRes.data;
381
596
  /*
382
- * 命中 limit 说明可能还有更早的 PR 未返回。
383
- * 去掉 --state=open 后列表按 updatedAt 降序返回「最近 30 条」,
384
- * 若账号历史 PR 超过 30 条,界面会静默少显示——
385
- * 用户无从知道「列表到此为止」还是「数据丢了」。
386
- * 必须把截断事实透出到 UI。
597
+ * PR「还有更多」判定。
598
+ *
599
+ * 优先用账号级真实总数做精确比较——`accountCounts.prTotal` 与列表查询
600
+ * 是**同一个口径**(都是 `is:pr author:@me`),所以 `loaded < total` 就是定义。
601
+ * 拿不到总数才回落到「本次是否拉满」的启发式。
602
+ *
603
+ * 为什么精确比较重要:`>= prLimit` 在边界会误报——用户点够次数后页脚一直显示
604
+ * 「加载更多」,点下去却一条都拉不到。
387
605
  */
388
- if (myPrs.length >= PR_SEARCH_LIMIT)
389
- prsTruncated = true;
606
+ prsTruncated =
607
+ // 顶到单次上限后「再加载」不可能有新结果(parseListLimit 会把更大的 limit 夹回 100),
608
+ // 必须停止下发 truncated,否则 loaded < total 恒成立 → 页脚永远显示、点不动
609
+ prLimit < LIST_LIMIT_MAX &&
610
+ (accountCounts?.prTotal !== undefined ? myPrs.length < accountCounts.prTotal : myPrs.length >= prLimit);
390
611
  }
391
612
  // 多源 Issues 聚合、按 URL 唯一去重并排序
392
613
  const issueMap = new Map();
@@ -402,6 +623,24 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
402
623
  collectIssues(ownerIssuesRes);
403
624
  collectIssues(assignedIssuesRes);
404
625
  collectIssues(authorIssuesRes);
626
+ /*
627
+ * Issues 的「还有更多」判定:三路检索只要有一路拉满,就说明可能还有更早的记录。
628
+ * 三路结果会被去重合并,所以不能拿 myIssues.length 反推(去重后天然变小)。
629
+ *
630
+ * 这里**故意不用** accountCounts.issueTotal 做精确比较:那个总数用的是
631
+ * `is:issue involves:@me`(author/assignee/mention/commenter 的超集),
632
+ * 而列表是三路(owner/assignee/author)的并集,前者恒 ≥ 后者。
633
+ * 拿它比较会得到 `loaded < total` 永远成立 → 页脚永远显示、点了却拉不到东西。
634
+ */
635
+ for (const r of [ownerIssuesRes, assignedIssuesRes, authorIssuesRes]) {
636
+ // 已顶到单次上限时不再喊「还有更多」:再点也只会拿到同样的数据(死循环)
637
+ if (issueLimit >= LIST_LIMIT_MAX)
638
+ break;
639
+ if (r.ok && Array.isArray(r.data) && r.data.length >= issueLimit) {
640
+ issuesTruncated = true;
641
+ break;
642
+ }
643
+ }
405
644
  const rawList = Array.from(issueMap.values());
406
645
  // 统一按最后更新时间降序排列
407
646
  rawList.sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime());
@@ -460,9 +699,12 @@ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, ch
460
699
  ...(workspaceError ? { workspaceError } : {}),
461
700
  // 仅在真的触顶时才下发,避免给正常场景增加无意义字段
462
701
  ...(prsTruncated ? { prsTruncated: true } : {}),
702
+ ...(issuesTruncated ? { issuesTruncated: true } : {}),
703
+ // 账号级总量(与分页无关);计数查询失败时整组缺席,前端回落已加载条数
704
+ ...(accountCounts || {}),
463
705
  ...(globalNotices.length > 0 ? { notices: globalNotices } : {}),
464
706
  };
465
- globalCache.set('global', globalData, now);
707
+ globalCache.set(globalCacheKey, globalData, now);
466
708
  sendJson(res, 200, { ok: true, data: globalData, fromCache: false });
467
709
  }
468
710
  catch (err) {
@@ -1,4 +1,4 @@
1
- import { sendJson, readJsonBody } from './route-helpers.js';
1
+ import { sendJson, readJsonBody, guardLocalWrite } from './route-helpers.js';
2
2
  export function registerProxyRoutes(deps, checker, proxyManager) {
3
3
  const { bindRoute } = deps;
4
4
  // 1. 代理配置:GET 读取状态 / POST 保存并立即生效(写 proxy.json + 重应用 dispatcher,零插件重载)
@@ -18,6 +18,9 @@ export function registerProxyRoutes(deps, checker, proxyManager) {
18
18
  sendJson(res, 405, { ok: false, message: '仅支持 GET / POST 请求' });
19
19
  return;
20
20
  }
21
+ // 跨站门禁:代理配置写入会改变插件全部外联流量的走向。
22
+ if (!guardLocalWrite(req, res, { requireJson: true }))
23
+ return;
21
24
  if (!proxyManager) {
22
25
  sendJson(res, 503, { ok: false, message: '当前运行环境未启用代理管理器' });
23
26
  return;
@@ -47,6 +50,10 @@ export function registerProxyRoutes(deps, checker, proxyManager) {
47
50
  sendJson(res, 405, { ok: false, message: '仅支持 POST 请求' });
48
51
  return;
49
52
  }
53
+ // 无请求体,只校验 Origin。保持「每个 POST 端点都有跨站门禁」这条不变量,
54
+ // 否则下一个人加端点时又会漏。
55
+ if (!guardLocalWrite(req, res))
56
+ return;
50
57
  if (!proxyManager) {
51
58
  sendJson(res, 503, { ok: false, message: '当前运行环境未启用代理管理器' });
52
59
  return;
@@ -16,4 +16,14 @@ import { UpgradeManager } from '../upgrade-manager.js';
16
16
  import { globalCacheTtlMs } from './route-helpers.js';
17
17
  import { type ProxyRouteManager } from './routes-proxy.js';
18
18
  export { globalCacheTtlMs };
19
- export declare function registerApiRoutes(ctxOrWebServer: any, executor: GhExecutor, workspaceRegistry?: any, cacheTtlMs?: number, updateChecker?: UpdateChecker, upgradeManager?: UpgradeManager, proxyManager?: ProxyRouteManager): void;
19
+ export declare function registerApiRoutes(ctxOrWebServer: any, executor: GhExecutor, cacheTtlMs?: number, updateChecker?: UpdateChecker, upgradeManager?: UpgradeManager, proxyManager?: ProxyRouteManager,
20
+ /**
21
+ * 显式 webServer 覆盖。
22
+ *
23
+ * 为什么必须显式传:cordis 的服务只有 `inject` 过才会挂在 `ctx` 上,而未 inject 时
24
+ * 读 `ctx.webServer` **不是返回 undefined,而是直接抛错**——
25
+ * `cannot get property "webServer" without inject`(cordis `src/reflect.ts:144-169`
26
+ * 的 Proxy get 陷阱)。所以 `ctx?.webServer` 这种"安全回退"根本不安全:它在
27
+ * CLI/TUI profile 里会把插件从「PENDING」变成「加载失败」。
28
+ */
29
+ webServerOverride?: any): void;
package/lib/api/routes.js CHANGED
@@ -8,9 +8,22 @@ import { registerLifecycleRoutes } from './routes-lifecycle.js';
8
8
  import { registerProxyRoutes } from './routes-proxy.js';
9
9
  // 再导出历史公共接口:globalCacheTtlMs 曾被测试直接 import,保持路径稳定避免破坏消费方
10
10
  export { globalCacheTtlMs };
11
- export function registerApiRoutes(ctxOrWebServer, executor, workspaceRegistry, cacheTtlMs = 15_000, updateChecker, upgradeManager, proxyManager) {
11
+ export function registerApiRoutes(ctxOrWebServer, executor, cacheTtlMs = 15_000, updateChecker, upgradeManager, proxyManager,
12
+ /**
13
+ * 显式 webServer 覆盖。
14
+ *
15
+ * 为什么必须显式传:cordis 的服务只有 `inject` 过才会挂在 `ctx` 上,而未 inject 时
16
+ * 读 `ctx.webServer` **不是返回 undefined,而是直接抛错**——
17
+ * `cannot get property "webServer" without inject`(cordis `src/reflect.ts:144-169`
18
+ * 的 Proxy get 陷阱)。所以 `ctx?.webServer` 这种"安全回退"根本不安全:它在
19
+ * CLI/TUI profile 里会把插件从「PENDING」变成「加载失败」。
20
+ */
21
+ webServerOverride) {
12
22
  const ctx = ctxOrWebServer;
13
- const webServer = ctx?.webServer || ctx;
23
+ // 只对「真 Context」走 ctx.get():ctx.get 是 Context 原型方法,读它永远安全;
24
+ // 而 `ctx.webServer` 只有确定 ctx 不是 cordis Proxy(即测试用的普通对象)时才能读。
25
+ const isCordisContext = typeof ctx?.get === 'function';
26
+ const webServer = webServerOverride ?? (isCordisContext ? ctx.get('webServer') : (ctx?.webServer ?? ctx));
14
27
  if (!webServer || typeof webServer.register !== 'function')
15
28
  return;
16
29
  const checker = updateChecker || new UpdateChecker();
package/lib/cache.d.ts CHANGED
@@ -74,25 +74,4 @@ export declare class TtlCache<T> {
74
74
  * 与 matchesFingerprint 分开:判定与处置是两件事,调用点语义更清晰。
75
75
  */
76
76
  invalidateIfFingerprintChanged(key: string, currentFingerprint: string | null | undefined): boolean;
77
- /**
78
- * 身份校验:缓存里的「仓库身份」是否仍与当前事实一致。
79
- *
80
- * @deprecated 自 S1 起由 matchesFingerprint 取代,**当前生产代码与测试均无调用点**。
81
- * 保留的唯一目的是 S1 的回滚路径:若指纹方案出问题,把 routes-overview
82
- * 的两行调用切回身份比对即可,无需恢复已删除的代码。
83
- * 计划在 S2(离线解析对齐 git 规则)完成后连同 getRepoIdentityFromLocal 一并删除。
84
- *
85
- * 为什么必须取代它:该方法依赖 `.git/config` 的解析结果,而解析器在多 remote
86
- * 布局下会取错段(实测),导致身份恒失配 → 缓存永久失效。
87
- *
88
- * @param key 工作区路径
89
- * @param currentNameWithOwner 当前从本地 .git/config 解析出的 owner/repo
90
- */
91
- matchesIdentity(key: string, currentNameWithOwner: string | null | undefined): boolean;
92
- /**
93
- * 仅在身份失配时作废该键。返回是否真的作废。
94
- *
95
- * @deprecated 同 matchesIdentity,改用 invalidateIfFingerprintChanged。
96
- */
97
- invalidateIfIdentityChanged(key: string, currentNameWithOwner: string | null | undefined): boolean;
98
77
  }
package/lib/cache.js CHANGED
@@ -129,41 +129,4 @@ export class TtlCache {
129
129
  this.evict(key);
130
130
  return true;
131
131
  }
132
- /**
133
- * 身份校验:缓存里的「仓库身份」是否仍与当前事实一致。
134
- *
135
- * @deprecated 自 S1 起由 matchesFingerprint 取代,**当前生产代码与测试均无调用点**。
136
- * 保留的唯一目的是 S1 的回滚路径:若指纹方案出问题,把 routes-overview
137
- * 的两行调用切回身份比对即可,无需恢复已删除的代码。
138
- * 计划在 S2(离线解析对齐 git 规则)完成后连同 getRepoIdentityFromLocal 一并删除。
139
- *
140
- * 为什么必须取代它:该方法依赖 `.git/config` 的解析结果,而解析器在多 remote
141
- * 布局下会取错段(实测),导致身份恒失配 → 缓存永久失效。
142
- *
143
- * @param key 工作区路径
144
- * @param currentNameWithOwner 当前从本地 .git/config 解析出的 owner/repo
145
- */
146
- matchesIdentity(key, currentNameWithOwner) {
147
- if (!currentNameWithOwner)
148
- return true;
149
- const cached = this.fallbackStore.get(key) ?? this.store.get(key)?.data;
150
- if (!cached)
151
- return true;
152
- const cachedRepo = cached?.repo;
153
- const cachedName = typeof cachedRepo?.nameWithOwner === 'string' ? cachedRepo.nameWithOwner : '';
154
- if (!cachedName)
155
- return true;
156
- return cachedName.toLowerCase() === currentNameWithOwner.toLowerCase();
157
- }
158
- /**
159
- * 仅在身份失配时作废该键。返回是否真的作废。
160
- *
161
- * @deprecated 同 matchesIdentity,改用 invalidateIfFingerprintChanged。
162
- */
163
- invalidateIfIdentityChanged(key, currentNameWithOwner) {
164
- if (this.matchesIdentity(key, currentNameWithOwner))
165
- return false;
166
- this.evict(key);
167
- return true;
168
- }
169
132
  }
package/lib/client.d.ts CHANGED
@@ -22,7 +22,8 @@ import { SyncStatusBadge } from './ui/sync-status-badge.js';
22
22
  import { CommitDetailModal } from './ui/commit-modal.js';
23
23
  import { PrDetailModal } from './ui/pr-modal.js';
24
24
  import { IssueDetailModal } from './ui/issue-modal.js';
25
+ import { RunDetailModal } from './ui/run-modal.js';
25
26
  import { GlobalSyncSettingsModal } from './ui/sync-settings-modal.js';
26
27
  import { GithubHelpModal } from './ui/help-modal.js';
27
28
  import { GithubWorkspacePanel } from './ui/workspace-panel.js';
28
- export { formatRelativeTime, rollupCiStatus, reviewDecisionBadge, eventLabel, parseGitStatText, GITHUB_PANEL_CSS, injectStyles, renderOctocat, renderGitIcon, renderOctocatBadge, renderPluginBadge, getLocalSyncSettings, saveLocalSyncSettings, getStoredGlobalOverview, saveStoredGlobalOverview, clearStoredGlobalOverview, getCacheStorageMeta, getRightbarStorageKey, getStoredRightbarSnapshot, saveStoredRightbarData, getStoredHeaderCardsVisible, saveStoredHeaderCardsVisible, HEADER_CARDS_STORAGE_KEY, getStoredRightbarStateFilter, saveStoredRightbarStateFilter, PR_STATE_FILTERS, ISSUE_STATE_FILTERS, BranchSelectorDropdown, RepoSelectorDropdown, AuthGuideCard, UpgradeNotificationCard, NetworkAlertBanner, RepoNoticeBar, resolveRepoBadges, SyncStatusBadge, CommitDetailModal, PrDetailModal, IssueDetailModal, GlobalSyncSettingsModal, GithubHelpModal, GithubWorkspacePanel, };
29
+ export { formatRelativeTime, rollupCiStatus, reviewDecisionBadge, eventLabel, parseGitStatText, GITHUB_PANEL_CSS, injectStyles, renderOctocat, renderGitIcon, renderOctocatBadge, renderPluginBadge, getLocalSyncSettings, saveLocalSyncSettings, getStoredGlobalOverview, saveStoredGlobalOverview, clearStoredGlobalOverview, getCacheStorageMeta, getRightbarStorageKey, getStoredRightbarSnapshot, saveStoredRightbarData, getStoredHeaderCardsVisible, saveStoredHeaderCardsVisible, HEADER_CARDS_STORAGE_KEY, getStoredRightbarStateFilter, saveStoredRightbarStateFilter, PR_STATE_FILTERS, ISSUE_STATE_FILTERS, BranchSelectorDropdown, RepoSelectorDropdown, AuthGuideCard, UpgradeNotificationCard, NetworkAlertBanner, RepoNoticeBar, resolveRepoBadges, SyncStatusBadge, CommitDetailModal, PrDetailModal, IssueDetailModal, RunDetailModal, GlobalSyncSettingsModal, GithubHelpModal, GithubWorkspacePanel, };