dsh-github-flow 0.5.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.
- package/README.en.md +16 -4
- package/README.md +16 -4
- package/docs/UPGRADE-NOTES.md +8 -8
- package/docs/architecture.md +33 -17
- package/docs/images/1.png +0 -0
- package/lib/api/routes-actions.js +139 -8
- package/lib/api/routes-overview.js +147 -29
- package/lib/cache.d.ts +77 -4
- package/lib/cache.js +114 -7
- package/lib/client.d.ts +7 -3
- package/lib/client.js +3309 -314
- package/lib/executor/error-classifier.d.ts +55 -0
- package/lib/executor/error-classifier.js +199 -0
- package/lib/executor/git-parser.d.ts +124 -0
- package/lib/executor/git-parser.js +397 -0
- package/lib/executor.d.ts +122 -4
- package/lib/executor.js +685 -80
- package/lib/tools/help.js +22 -7
- package/lib/tools/image-utils.d.ts +27 -0
- package/lib/tools/image-utils.js +73 -3
- package/lib/types.d.ts +331 -0
- package/lib/ui/help-modal.d.ts +6 -0
- package/lib/ui/help-modal.js +113 -15
- package/lib/ui/issue-modal.d.ts +14 -0
- package/lib/ui/issue-modal.js +366 -0
- package/lib/ui/markdown-lite.d.ts +66 -0
- package/lib/ui/markdown-lite.js +312 -0
- package/lib/ui/network-banner.d.ts +12 -0
- package/lib/ui/network-banner.js +35 -0
- package/lib/ui/pr-modal.d.ts +17 -0
- package/lib/ui/pr-modal.js +516 -0
- package/lib/ui/repo-badges.d.ts +43 -0
- package/lib/ui/repo-badges.js +82 -0
- package/lib/ui/repo-selector.d.ts +14 -0
- package/lib/ui/repo-selector.js +112 -0
- package/lib/ui/storage.d.ts +47 -0
- package/lib/ui/storage.js +139 -0
- package/lib/ui/styles.d.ts +1 -1
- package/lib/ui/styles.js +960 -0
- package/lib/ui/utils.js +13 -1
- package/lib/ui/workspace-panel.js +169 -27
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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([
|
|
72
|
-
|
|
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
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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', '--
|
|
227
|
-
executor.run(['search', 'issues', '--owner=@me', '--
|
|
228
|
-
executor.run(['search', 'issues', '--assignee=@me', '--
|
|
229
|
-
executor.run(['search', 'issues', '--author=@me', '--
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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 });
|
package/lib/cache.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 有上限的 TTL 缓存。
|
|
3
3
|
*
|
|
4
|
-
* 为什么独立成模块:缓存语义(过期、LRU
|
|
5
|
-
*
|
|
4
|
+
* 为什么独立成模块:缓存语义(过期、LRU 腾退、容量上限、身份失效)是缺陷高发区,
|
|
5
|
+
* 但嵌在路由文件里就无法单测——import 一个已执行过的路由模块拿不到干净实例。
|
|
6
|
+
* 独立出来才能覆盖边界。
|
|
6
7
|
*/
|
|
7
8
|
export declare class TtlCache<T> {
|
|
8
9
|
private ttlMs;
|
|
@@ -10,6 +11,16 @@ export declare class TtlCache<T> {
|
|
|
10
11
|
private store;
|
|
11
12
|
/** 容灾备份底座:记录最后一次成功的有效快照,不受 TTL 过期或主动 refresh 清空的影响 */
|
|
12
13
|
private fallbackStore;
|
|
14
|
+
/**
|
|
15
|
+
* 每个 key 对应的 remote 配置指纹。
|
|
16
|
+
*
|
|
17
|
+
* 为什么独立于 store 而不是塞进 value:store 的 value 是业务数据 T,
|
|
18
|
+
* 混入簿记字段会污染 T 的类型、序列化与消费方断言。指纹是缓存层自己的
|
|
19
|
+
* 记账信息,不参与业务读取。
|
|
20
|
+
*
|
|
21
|
+
* 缺失(Map 中无该 key)= 调用方未提供指纹,此时永不作废(保守)。
|
|
22
|
+
*/
|
|
23
|
+
private fingerprintStore;
|
|
13
24
|
/**
|
|
14
25
|
* @param ttlMs 命中有效期;由面向用户的 cacheTtlMs 配置驱动
|
|
15
26
|
* @param maxEntries 容量上限。键通常是工作区路径,一次会话就可能引入若干键,
|
|
@@ -17,9 +28,71 @@ export declare class TtlCache<T> {
|
|
|
17
28
|
*/
|
|
18
29
|
constructor(ttlMs: number, maxEntries?: number);
|
|
19
30
|
get(key: string, now: number): T | undefined;
|
|
20
|
-
|
|
31
|
+
/**
|
|
32
|
+
* @param fingerprint 该 key 对应的 remote 配置指纹(可选)。
|
|
33
|
+
*
|
|
34
|
+
* 只在传入**非空字符串**时更新指纹;传入 undefined / null 一律保留旧值。
|
|
35
|
+
*
|
|
36
|
+
* 为什么 null 不能清除旧指纹:null 的真实含义是「这一次没读到」(config 被
|
|
37
|
+
* 占用、瞬时 IO 失败、路径暂不可达),不是「这个仓库没有 remote」。若把 null
|
|
38
|
+
* 当作清除,一次瞬时读取失败就会永久抹掉失效检测能力——之后 remote 真的变了
|
|
39
|
+
* 也永远发现不了,缓存里冻结的身份再也出不来。保留旧值时的行为是
|
|
40
|
+
* matchesFingerprint 返回 true(保守不作废),等下一次读到时再正确判定,
|
|
41
|
+
* 严格优于「永久失明」。
|
|
42
|
+
*/
|
|
43
|
+
set(key: string, data: T, now: number, fingerprint?: string | null): void;
|
|
44
|
+
/** 三张表必须同步腾退,否则指纹会随 key 数量慢性增长 */
|
|
45
|
+
private evict;
|
|
21
46
|
/** 获取最后一次有效成功的容灾底座数据(专供网络故障、超时时的 Fail-Safe 保全) */
|
|
22
47
|
getFallback(key: string): T | undefined;
|
|
23
48
|
get size(): number;
|
|
24
|
-
|
|
49
|
+
/**
|
|
50
|
+
* 清空缓存。
|
|
51
|
+
*
|
|
52
|
+
* @param options.keepFallback 是否保留 fallbackStore 容灾底座,默认 true。
|
|
53
|
+
* 默认保留是为了「网络抖动时宁可给旧数据也别白屏」;但用户显式点击
|
|
54
|
+
* 「重试刷新」时要的语义恰恰相反——宁可空白也要重来,此时必须传 false,
|
|
55
|
+
* 否则刷新动作会被容灾底座架空(这是真实踩到过的死循环:配置文件已变、
|
|
56
|
+
* 旧快照永久冻结,刷新按钮点了等于没点)。
|
|
57
|
+
*/
|
|
58
|
+
clear(options?: {
|
|
59
|
+
keepFallback?: boolean;
|
|
60
|
+
}): void;
|
|
61
|
+
/**
|
|
62
|
+
* remote 配置指纹是否仍与当前事实一致。
|
|
63
|
+
*
|
|
64
|
+
* 为什么用指纹取代「身份字符串比对」:身份比对把解析器的正确性变成了缓存
|
|
65
|
+
* 有效性的前置条件——解析一偏(如双 remote 时取错 remote 段),比对永远
|
|
66
|
+
* 失配,缓存永久不命中,每次请求都重拉全部 gh 子进程。
|
|
67
|
+
* 指纹只回答「配置变了没有」,不依赖任何语义理解,因此对该类偏差免疫。
|
|
68
|
+
*
|
|
69
|
+
* 判定口径刻意保守:任一侧取不到指纹(离线、非 Git、无 remote)即视为一致。
|
|
70
|
+
*/
|
|
71
|
+
matchesFingerprint(key: string, currentFingerprint: string | null | undefined): boolean;
|
|
72
|
+
/**
|
|
73
|
+
* 仅在指纹失配时作废该 key(含容灾底座)。返回是否真的作废。
|
|
74
|
+
* 与 matchesFingerprint 分开:判定与处置是两件事,调用点语义更清晰。
|
|
75
|
+
*/
|
|
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;
|
|
25
98
|
}
|
package/lib/cache.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 有上限的 TTL 缓存。
|
|
3
3
|
*
|
|
4
|
-
* 为什么独立成模块:缓存语义(过期、LRU
|
|
5
|
-
*
|
|
4
|
+
* 为什么独立成模块:缓存语义(过期、LRU 腾退、容量上限、身份失效)是缺陷高发区,
|
|
5
|
+
* 但嵌在路由文件里就无法单测——import 一个已执行过的路由模块拿不到干净实例。
|
|
6
|
+
* 独立出来才能覆盖边界。
|
|
6
7
|
*/
|
|
7
8
|
export class TtlCache {
|
|
8
9
|
ttlMs;
|
|
@@ -10,6 +11,16 @@ export class TtlCache {
|
|
|
10
11
|
store = new Map();
|
|
11
12
|
/** 容灾备份底座:记录最后一次成功的有效快照,不受 TTL 过期或主动 refresh 清空的影响 */
|
|
12
13
|
fallbackStore = new Map();
|
|
14
|
+
/**
|
|
15
|
+
* 每个 key 对应的 remote 配置指纹。
|
|
16
|
+
*
|
|
17
|
+
* 为什么独立于 store 而不是塞进 value:store 的 value 是业务数据 T,
|
|
18
|
+
* 混入簿记字段会污染 T 的类型、序列化与消费方断言。指纹是缓存层自己的
|
|
19
|
+
* 记账信息,不参与业务读取。
|
|
20
|
+
*
|
|
21
|
+
* 缺失(Map 中无该 key)= 调用方未提供指纹,此时永不作废(保守)。
|
|
22
|
+
*/
|
|
23
|
+
fingerprintStore = new Map();
|
|
13
24
|
/**
|
|
14
25
|
* @param ttlMs 命中有效期;由面向用户的 cacheTtlMs 配置驱动
|
|
15
26
|
* @param maxEntries 容量上限。键通常是工作区路径,一次会话就可能引入若干键,
|
|
@@ -32,22 +43,43 @@ export class TtlCache {
|
|
|
32
43
|
this.store.set(key, hit);
|
|
33
44
|
return hit.data;
|
|
34
45
|
}
|
|
35
|
-
|
|
46
|
+
/**
|
|
47
|
+
* @param fingerprint 该 key 对应的 remote 配置指纹(可选)。
|
|
48
|
+
*
|
|
49
|
+
* 只在传入**非空字符串**时更新指纹;传入 undefined / null 一律保留旧值。
|
|
50
|
+
*
|
|
51
|
+
* 为什么 null 不能清除旧指纹:null 的真实含义是「这一次没读到」(config 被
|
|
52
|
+
* 占用、瞬时 IO 失败、路径暂不可达),不是「这个仓库没有 remote」。若把 null
|
|
53
|
+
* 当作清除,一次瞬时读取失败就会永久抹掉失效检测能力——之后 remote 真的变了
|
|
54
|
+
* 也永远发现不了,缓存里冻结的身份再也出不来。保留旧值时的行为是
|
|
55
|
+
* matchesFingerprint 返回 true(保守不作废),等下一次读到时再正确判定,
|
|
56
|
+
* 严格优于「永久失明」。
|
|
57
|
+
*/
|
|
58
|
+
set(key, data, now, fingerprint) {
|
|
36
59
|
this.store.set(key, { data, time: now });
|
|
37
60
|
this.fallbackStore.set(key, data);
|
|
61
|
+
if (typeof fingerprint === 'string' && fingerprint) {
|
|
62
|
+
this.fingerprintStore.set(key, fingerprint);
|
|
63
|
+
}
|
|
38
64
|
while (this.store.size > this.maxEntries) {
|
|
39
65
|
const oldest = this.store.keys().next();
|
|
40
66
|
if (oldest.done)
|
|
41
67
|
break;
|
|
42
|
-
this.
|
|
68
|
+
this.evict(oldest.value);
|
|
43
69
|
}
|
|
44
70
|
while (this.fallbackStore.size > this.maxEntries) {
|
|
45
71
|
const oldest = this.fallbackStore.keys().next();
|
|
46
72
|
if (oldest.done)
|
|
47
73
|
break;
|
|
48
|
-
this.
|
|
74
|
+
this.evict(oldest.value);
|
|
49
75
|
}
|
|
50
76
|
}
|
|
77
|
+
/** 三张表必须同步腾退,否则指纹会随 key 数量慢性增长 */
|
|
78
|
+
evict(key) {
|
|
79
|
+
this.store.delete(key);
|
|
80
|
+
this.fallbackStore.delete(key);
|
|
81
|
+
this.fingerprintStore.delete(key);
|
|
82
|
+
}
|
|
51
83
|
/** 获取最后一次有效成功的容灾底座数据(专供网络故障、超时时的 Fail-Safe 保全) */
|
|
52
84
|
getFallback(key) {
|
|
53
85
|
return this.fallbackStore.get(key);
|
|
@@ -55,8 +87,83 @@ export class TtlCache {
|
|
|
55
87
|
get size() {
|
|
56
88
|
return this.store.size;
|
|
57
89
|
}
|
|
58
|
-
|
|
59
|
-
|
|
90
|
+
/**
|
|
91
|
+
* 清空缓存。
|
|
92
|
+
*
|
|
93
|
+
* @param options.keepFallback 是否保留 fallbackStore 容灾底座,默认 true。
|
|
94
|
+
* 默认保留是为了「网络抖动时宁可给旧数据也别白屏」;但用户显式点击
|
|
95
|
+
* 「重试刷新」时要的语义恰恰相反——宁可空白也要重来,此时必须传 false,
|
|
96
|
+
* 否则刷新动作会被容灾底座架空(这是真实踩到过的死循环:配置文件已变、
|
|
97
|
+
* 旧快照永久冻结,刷新按钮点了等于没点)。
|
|
98
|
+
*/
|
|
99
|
+
clear(options = {}) {
|
|
60
100
|
this.store.clear();
|
|
101
|
+
if (options.keepFallback === false) {
|
|
102
|
+
this.fallbackStore.clear();
|
|
103
|
+
this.fingerprintStore.clear();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* remote 配置指纹是否仍与当前事实一致。
|
|
108
|
+
*
|
|
109
|
+
* 为什么用指纹取代「身份字符串比对」:身份比对把解析器的正确性变成了缓存
|
|
110
|
+
* 有效性的前置条件——解析一偏(如双 remote 时取错 remote 段),比对永远
|
|
111
|
+
* 失配,缓存永久不命中,每次请求都重拉全部 gh 子进程。
|
|
112
|
+
* 指纹只回答「配置变了没有」,不依赖任何语义理解,因此对该类偏差免疫。
|
|
113
|
+
*
|
|
114
|
+
* 判定口径刻意保守:任一侧取不到指纹(离线、非 Git、无 remote)即视为一致。
|
|
115
|
+
*/
|
|
116
|
+
matchesFingerprint(key, currentFingerprint) {
|
|
117
|
+
const stored = this.fingerprintStore.get(key);
|
|
118
|
+
if (!stored || !currentFingerprint)
|
|
119
|
+
return true;
|
|
120
|
+
return stored === currentFingerprint;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* 仅在指纹失配时作废该 key(含容灾底座)。返回是否真的作废。
|
|
124
|
+
* 与 matchesFingerprint 分开:判定与处置是两件事,调用点语义更清晰。
|
|
125
|
+
*/
|
|
126
|
+
invalidateIfFingerprintChanged(key, currentFingerprint) {
|
|
127
|
+
if (this.matchesFingerprint(key, currentFingerprint))
|
|
128
|
+
return false;
|
|
129
|
+
this.evict(key);
|
|
130
|
+
return true;
|
|
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;
|
|
61
168
|
}
|
|
62
169
|
}
|
package/lib/client.d.ts
CHANGED
|
@@ -11,14 +11,18 @@ declare global {
|
|
|
11
11
|
import { formatRelativeTime, rollupCiStatus, reviewDecisionBadge, eventLabel, parseGitStatText } from './ui/utils.js';
|
|
12
12
|
import { GITHUB_PANEL_CSS, injectStyles } from './ui/styles.js';
|
|
13
13
|
import { renderOctocat, renderGitIcon, renderOctocatBadge, renderPluginBadge } from './ui/icons.js';
|
|
14
|
-
import { getLocalSyncSettings, saveLocalSyncSettings, getStoredGlobalOverview, saveStoredGlobalOverview, clearStoredGlobalOverview, getCacheStorageMeta, getRightbarStorageKey, getStoredRightbarSnapshot, saveStoredRightbarData, getStoredHeaderCardsVisible, saveStoredHeaderCardsVisible, HEADER_CARDS_STORAGE_KEY } from './ui/storage.js';
|
|
14
|
+
import { getLocalSyncSettings, saveLocalSyncSettings, getStoredGlobalOverview, saveStoredGlobalOverview, clearStoredGlobalOverview, getCacheStorageMeta, getRightbarStorageKey, getStoredRightbarSnapshot, saveStoredRightbarData, getStoredHeaderCardsVisible, saveStoredHeaderCardsVisible, HEADER_CARDS_STORAGE_KEY, getStoredRightbarStateFilter, saveStoredRightbarStateFilter, PR_STATE_FILTERS, ISSUE_STATE_FILTERS } from './ui/storage.js';
|
|
15
15
|
import { BranchSelectorDropdown } from './ui/branch-selector.js';
|
|
16
|
+
import { RepoSelectorDropdown } from './ui/repo-selector.js';
|
|
16
17
|
import { AuthGuideCard } from './ui/auth-guide-card.js';
|
|
17
18
|
import { UpgradeNotificationCard } from './ui/upgrade-card.js';
|
|
18
|
-
import { NetworkAlertBanner } from './ui/network-banner.js';
|
|
19
|
+
import { NetworkAlertBanner, RepoNoticeBar } from './ui/network-banner.js';
|
|
20
|
+
import { resolveRepoBadges } from './ui/repo-badges.js';
|
|
19
21
|
import { SyncStatusBadge } from './ui/sync-status-badge.js';
|
|
20
22
|
import { CommitDetailModal } from './ui/commit-modal.js';
|
|
23
|
+
import { PrDetailModal } from './ui/pr-modal.js';
|
|
24
|
+
import { IssueDetailModal } from './ui/issue-modal.js';
|
|
21
25
|
import { GlobalSyncSettingsModal } from './ui/sync-settings-modal.js';
|
|
22
26
|
import { GithubHelpModal } from './ui/help-modal.js';
|
|
23
27
|
import { GithubWorkspacePanel } from './ui/workspace-panel.js';
|
|
24
|
-
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, BranchSelectorDropdown, AuthGuideCard, UpgradeNotificationCard, NetworkAlertBanner, SyncStatusBadge, CommitDetailModal, GlobalSyncSettingsModal, GithubHelpModal, GithubWorkspacePanel, };
|
|
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, };
|