dsh-github-flow 0.1.8 → 0.2.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 (60) hide show
  1. package/README.en.md +139 -54
  2. package/README.md +142 -54
  3. package/docs/architecture.md +19 -4
  4. package/docs/images/1.png +0 -0
  5. package/docs/images/3.png +0 -0
  6. package/docs/images/4.png +0 -0
  7. package/docs/npm-publish-guide.md +12 -0
  8. package/lib/api/route-helpers.d.ts +47 -0
  9. package/lib/api/route-helpers.js +134 -0
  10. package/lib/api/routes-actions.d.ts +9 -0
  11. package/lib/api/routes-actions.js +115 -0
  12. package/lib/api/routes-lifecycle.d.ts +9 -0
  13. package/lib/api/routes-lifecycle.js +75 -0
  14. package/lib/api/routes-overview.d.ts +11 -0
  15. package/lib/api/routes-overview.js +227 -0
  16. package/lib/api/routes-proxy.d.ts +18 -0
  17. package/lib/api/routes-proxy.js +63 -0
  18. package/lib/api/routes.d.ts +16 -6
  19. package/lib/api/routes.js +14 -504
  20. package/lib/client.js +383 -55
  21. package/lib/executor.d.ts +17 -0
  22. package/lib/executor.js +28 -0
  23. package/lib/main.d.ts +1 -0
  24. package/lib/main.js +40 -9
  25. package/lib/proxy.d.ts +87 -0
  26. package/lib/proxy.js +275 -0
  27. package/lib/renderers/markdown.d.ts +24 -0
  28. package/lib/renderers/markdown.js +493 -0
  29. package/lib/tools/api.d.ts +4 -0
  30. package/lib/tools/api.js +14 -4
  31. package/lib/tools/help.d.ts +31 -0
  32. package/lib/tools/help.js +212 -0
  33. package/lib/tools/issue.d.ts +32 -1
  34. package/lib/tools/issue.js +94 -17
  35. package/lib/tools/pr.d.ts +22 -1
  36. package/lib/tools/pr.js +67 -15
  37. package/lib/tools/release.d.ts +89 -0
  38. package/lib/tools/release.js +219 -0
  39. package/lib/tools/repo.d.ts +13 -1
  40. package/lib/tools/repo.js +46 -14
  41. package/lib/tools/run.d.ts +22 -1
  42. package/lib/tools/run.js +74 -14
  43. package/lib/tools/secret.d.ts +60 -0
  44. package/lib/tools/secret.js +175 -0
  45. package/lib/tools/skill.d.ts +63 -0
  46. package/lib/tools/skill.js +146 -0
  47. package/lib/types.d.ts +12 -0
  48. package/lib/updater.d.ts +8 -0
  49. package/lib/updater.js +9 -0
  50. package/lib/upgrade-manager.d.ts +4 -0
  51. package/lib/upgrade-manager.js +11 -1
  52. package/package.json +3 -2
  53. package/lib/client/icons.d.ts +0 -13
  54. package/lib/client/icons.js +0 -41
  55. package/lib/client/index.d.ts +0 -3
  56. package/lib/client/index.js +0 -36
  57. package/lib/client/panel.d.ts +0 -1
  58. package/lib/client/panel.js +0 -102
  59. package/lib/client/style.d.ts +0 -1
  60. package/lib/client/style.js +0 -555
package/docs/images/3.png CHANGED
Binary file
Binary file
@@ -98,3 +98,15 @@ npm publish --access public
98
98
  ### Q2: 报错 `403 You cannot publish over the previously published versions`
99
99
  - **原因**:当前打 tag 的代码版本在 `package.json` 中的 `version` 已经被发布过了。
100
100
  - **解法**:本地执行 `npm version patch` 递增版本号后再重新打 tag 推送。
101
+
102
+ ### Q3: 刚发布了新版本(npm 与淘宝镜像均已更新),为什么用户执行 `dsh plugin add` 依然安装了旧版本?
103
+ - **原因**:**触发了 DSH 底层 pnpm 的供应链安全最小发布年龄保护机制(`minimum-release-age`)**。
104
+ 1. DSH 内置的包管理器(pnpm)默认启用了防供应链投毒与恶意包抢跑的防御机制,默认配置规则为 `minimum-release-age = 24 * 60`(即 24 小时 / 1 天);
105
+ 2. 当用户执行未指定版本号的裸名安装命令(如 `dsh plugin add dsh-github-flow`)时,pnpm 的 `filterPkgMetadataByPublishDate` 过滤器会自动剔除发布未满 24 小时的新版本,并将解析上下文中的 `dist-tags.latest` 动态降级回退到**满足安全年龄的上一版本**;
106
+ 3. 用户终端安装输出中的第一行 `✓ Lockfile passes supply-chain policies (...)` 即为该供应链策略生效的标志。
107
+ - **解法**:
108
+ 1. **立即尝鲜安装最新版**:在命令末尾**显式指定目标版本号**(pnpm 会判定为开发者显式信任指定版本,将其加入 `minimumReleaseAgeExclude` 白名单并成功安装):
109
+ ```powershell
110
+ dsh plugin --profile desktop add dsh-github-flow@0.1.8
111
+ ```
112
+ 2. **自然解禁**:新版本在 npm 上的发布时间达到 24 小时后,安全锁定期自动解除,届时所有用户直接运行裸名安装命令均会默认解析安装最新版。
@@ -0,0 +1,47 @@
1
+ import type { IssueCategory, IssueStats } from '../types.js';
2
+ /**
3
+ * 依据当前登录账号判定 Issue 的归属身份维度 (可多选):
4
+ * - reported: 用户提起的 (属于当前账号名下仓库,但作者不是本人)
5
+ * - assigned: 被分配的 (指派列表中包含当前账号)
6
+ * - created: 我创建的 (作者是当前账号)
7
+ *
8
+ * 为什么这样划分:
9
+ * 在真实工程实践中,Maintainer 不仅要关注自己创建的任务,更要对社区/用户提交的 Bug 反馈负责,
10
+ * 同时还要处理他人指派的待办任务。这三个维度互补且可能重叠。
11
+ */
12
+ export declare function resolveIssueCategories(issue: {
13
+ author?: {
14
+ login?: string;
15
+ } | null;
16
+ assignees?: Array<{
17
+ login?: string;
18
+ }> | null;
19
+ repository?: {
20
+ nameWithOwner?: string;
21
+ } | null;
22
+ }, currentUser?: string, explicitRepoOwner?: string): IssueCategory[];
23
+ export declare function calculateIssueStats(issues: Array<{
24
+ categories?: IssueCategory[];
25
+ }>): IssueStats;
26
+ /** path.basename 在空串/异常输入上会抛错,工作区路径来自磁盘文件,必须防御 */
27
+ export declare function basenameSafe(p: string): string;
28
+ /**
29
+ * 由用户配置推导全局驾驶舱的缓存时长。
30
+ * 抽成导出的纯函数:TTL 的推导规则是测试断言的对象,埋在闭包里就只能靠猜。
31
+ */
32
+ export declare function globalCacheTtlMs(cacheTtlMs: number): number;
33
+ /** open_browser 只允许 http/https,杜绝 file:// 与自定义协议被当作打开目标 */
34
+ export declare function isSafeExternalUrl(raw: unknown): raw is string;
35
+ /** JSON 响应统一出口:no-store 防代理/浏览器缓存干扰面板数据新鲜度语义 */
36
+ export declare function sendJson(res: any, status: number, data: any): void;
37
+ /**
38
+ * 读取并解析请求 JSON body(带默认 256KB 上限防护)。
39
+ * 集中处理超限/畸形 JSON 的错误语义,避免每个 handler 复制流收集样板。
40
+ */
41
+ export declare function readJsonBody(req: any, maxBytes?: number): Promise<any>;
42
+ /** 各路由分组共享的依赖包类型:注册器装配时一次性注入,分组内不再触碰全局 */
43
+ export interface RouteDeps {
44
+ bindRoute: (route: any) => void;
45
+ sendJson: typeof sendJson;
46
+ readJsonBody: typeof readJsonBody;
47
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * API 路由共享工具层。
3
+ *
4
+ * 为什么从 routes.ts 拆出:这些工具被全部路由分组文件消费(sendJson/readJsonBody/
5
+ * issue 聚合/TTL 推导),放在任何一个分组文件里都会造成分组之间的横向依赖;
6
+ * 独立成无状态模块后,各分组只依赖它,注册器只装配。
7
+ */
8
+ import path from 'node:path';
9
+ /**
10
+ * 依据当前登录账号判定 Issue 的归属身份维度 (可多选):
11
+ * - reported: 用户提起的 (属于当前账号名下仓库,但作者不是本人)
12
+ * - assigned: 被分配的 (指派列表中包含当前账号)
13
+ * - created: 我创建的 (作者是当前账号)
14
+ *
15
+ * 为什么这样划分:
16
+ * 在真实工程实践中,Maintainer 不仅要关注自己创建的任务,更要对社区/用户提交的 Bug 反馈负责,
17
+ * 同时还要处理他人指派的待办任务。这三个维度互补且可能重叠。
18
+ */
19
+ export function resolveIssueCategories(issue, currentUser, explicitRepoOwner) {
20
+ const categories = [];
21
+ if (!currentUser)
22
+ return categories;
23
+ const currentLower = currentUser.toLowerCase();
24
+ const authorLogin = issue.author?.login?.toLowerCase();
25
+ const isAuthor = Boolean(authorLogin && authorLogin === currentLower);
26
+ const isAssignee = Array.isArray(issue.assignees) &&
27
+ issue.assignees.some((a) => a.login?.toLowerCase() === currentLower);
28
+ // 仓库 owner 判定:优先从 repository.nameWithOwner 提取,兜底使用 explicitRepoOwner
29
+ let repoOwner = explicitRepoOwner?.toLowerCase();
30
+ if (!repoOwner && issue.repository?.nameWithOwner) {
31
+ repoOwner = issue.repository.nameWithOwner.split('/')[0]?.toLowerCase();
32
+ }
33
+ const isOwnerRepo = Boolean(repoOwner && repoOwner === currentLower);
34
+ if (isAuthor)
35
+ categories.push('created');
36
+ if (isAssignee)
37
+ categories.push('assigned');
38
+ // 如果是当前用户维护的仓库,且作者不是本人,则明确归为「用户提起的」
39
+ if (isOwnerRepo && !isAuthor)
40
+ categories.push('reported');
41
+ return categories;
42
+ }
43
+ export function calculateIssueStats(issues) {
44
+ let reported = 0;
45
+ let assigned = 0;
46
+ let created = 0;
47
+ for (const item of issues) {
48
+ const cats = item.categories || [];
49
+ if (cats.includes('reported'))
50
+ reported++;
51
+ if (cats.includes('assigned'))
52
+ assigned++;
53
+ if (cats.includes('created'))
54
+ created++;
55
+ }
56
+ return {
57
+ total: issues.length,
58
+ reported,
59
+ assigned,
60
+ created,
61
+ };
62
+ }
63
+ /** path.basename 在空串/异常输入上会抛错,工作区路径来自磁盘文件,必须防御 */
64
+ export function basenameSafe(p) {
65
+ try {
66
+ return path.basename(p) || p;
67
+ }
68
+ catch {
69
+ return p;
70
+ }
71
+ }
72
+ /**
73
+ * 全局驾驶舱的缓存时长下限。
74
+ * 这个端点要 spawn 十几次 gh,比单仓库概览重得多,因此不直接沿用 cacheTtlMs(默认 15s)。
75
+ * 定在 5 分钟的依据:它展示的是「我的仓库 / 待办 PR / Issues」这类分钟级不变的数据,
76
+ * 而每次刷新本机实测要 8-13 秒。缓存太短等于让用户反复等这段延迟——实测 60 秒缓存下,
77
+ * 用户 65 秒后再打开面板仍需等 9.7 秒。仍随 cacheTtlMs 缩放,用户调大时这里同步变长。
78
+ */
79
+ const GLOBAL_CACHE_MIN_TTL_MS = 300_000;
80
+ /**
81
+ * 由用户配置推导全局驾驶舱的缓存时长。
82
+ * 抽成导出的纯函数:TTL 的推导规则是测试断言的对象,埋在闭包里就只能靠猜。
83
+ */
84
+ export function globalCacheTtlMs(cacheTtlMs) {
85
+ const base = Number.isFinite(cacheTtlMs) && cacheTtlMs >= 0 ? cacheTtlMs : 15_000;
86
+ return Math.max(base * 20, GLOBAL_CACHE_MIN_TTL_MS);
87
+ }
88
+ /** open_browser 只允许 http/https,杜绝 file:// 与自定义协议被当作打开目标 */
89
+ export function isSafeExternalUrl(raw) {
90
+ if (typeof raw !== 'string' || raw.length === 0 || raw.length > 2048)
91
+ return false;
92
+ try {
93
+ const parsed = new URL(raw);
94
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ }
100
+ /** JSON 响应统一出口:no-store 防代理/浏览器缓存干扰面板数据新鲜度语义 */
101
+ export function sendJson(res, status, data) {
102
+ res.statusCode = status;
103
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
104
+ res.setHeader('Cache-Control', 'no-store');
105
+ res.end(JSON.stringify(data));
106
+ }
107
+ /**
108
+ * 读取并解析请求 JSON body(带默认 256KB 上限防护)。
109
+ * 集中处理超限/畸形 JSON 的错误语义,避免每个 handler 复制流收集样板。
110
+ */
111
+ export function readJsonBody(req, maxBytes = 256 * 1024) {
112
+ return new Promise((resolve, reject) => {
113
+ let body = '';
114
+ let size = 0;
115
+ req.on('data', (chunk) => {
116
+ size += chunk.length;
117
+ if (size > maxBytes) {
118
+ reject(new Error(`请求体超过 ${maxBytes} 字节上限`));
119
+ req.destroy();
120
+ return;
121
+ }
122
+ body += chunk;
123
+ });
124
+ req.on('end', () => {
125
+ try {
126
+ resolve(body ? JSON.parse(body) : {});
127
+ }
128
+ catch {
129
+ reject(new Error('请求体不是合法 JSON'));
130
+ }
131
+ });
132
+ req.on('error', reject);
133
+ });
134
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * 动作类路由:缓存强制刷新 + 右侧栏快捷操作(create_pr/create_issue/open_browser/open_terminal)。
3
+ * 共同特征:POST 触发、读少写多、无缓存语义,与概览类路由的演进方向不同。
4
+ */
5
+ import type { GhExecutor } from '../executor.js';
6
+ import { TtlCache } from '../cache.js';
7
+ import type { GitHubOverviewData, GlobalOverviewData } from '../types.js';
8
+ import { type RouteDeps } from './route-helpers.js';
9
+ export declare function registerActionRoutes(deps: RouteDeps, executor: GhExecutor, cacheMap: TtlCache<GitHubOverviewData>, globalCache: TtlCache<GlobalOverviewData>): void;
@@ -0,0 +1,115 @@
1
+ import { isSafeExternalUrl, sendJson } from './route-helpers.js';
2
+ export function registerActionRoutes(deps, executor, cacheMap, globalCache) {
3
+ const { bindRoute } = deps;
4
+ // 1. 强制刷新缓存
5
+ bindRoute({
6
+ kind: 'exact',
7
+ path: '/api/github/refresh',
8
+ handler: async (req, res) => {
9
+ cacheMap.clear();
10
+ globalCache.clear();
11
+ sendJson(res, 200, { ok: true, message: '缓存已清空,下次读取将拉取最新数据' });
12
+ },
13
+ });
14
+ // 2. 右侧栏快捷操作代理接口
15
+ bindRoute({
16
+ kind: 'exact',
17
+ path: '/api/github/action',
18
+ handler: async (req, res) => {
19
+ if (req.method !== 'POST') {
20
+ sendJson(res, 405, { ok: false, message: '仅支持 POST 请求' });
21
+ return;
22
+ }
23
+ let body = '';
24
+ req.on('data', (chunk) => { body += chunk; });
25
+ req.on('end', async () => {
26
+ try {
27
+ const payload = body ? JSON.parse(body) : {};
28
+ const cwd = payload.cwd || process.cwd();
29
+ switch (payload.action) {
30
+ case 'create_pr': {
31
+ const cmd = ['pr', 'create', '--title', payload.title || 'Update', '--body', payload.body || 'Created from DSH Rightbar'];
32
+ if (payload.draft)
33
+ cmd.push('--draft');
34
+ const r = await executor.run(cmd, { cwd });
35
+ if (!r.ok)
36
+ throw new Error(r.error);
37
+ sendJson(res, 200, { ok: true, message: 'PR 创建成功', url: r.rawOutput });
38
+ break;
39
+ }
40
+ case 'create_issue': {
41
+ const cmd = ['issue', 'create', '--title', payload.title || 'New Issue', '--body', payload.body || 'Created from DSH Rightbar'];
42
+ const r = await executor.run(cmd, { cwd });
43
+ if (!r.ok)
44
+ throw new Error(r.error);
45
+ sendJson(res, 200, { ok: true, message: 'Issue 创建成功', url: r.rawOutput });
46
+ break;
47
+ }
48
+ case 'open_browser': {
49
+ // 原实现把 payload.url 直接拼进 shell 字符串再交给 exec(),
50
+ // 形如 https://x" & calc & " 的 URL 可闭合引号并追加任意命令——本机任意页面
51
+ // 都能通过 POST 这个端点触发命令执行。
52
+ // 现在改为:先用 URL 解析白名单协议,再用 execFile 参数数组传递,全程不经过 shell。
53
+ if (!isSafeExternalUrl(payload.url)) {
54
+ throw new Error('url 参数无效:只允许 http/https 开头的完整地址');
55
+ }
56
+ const { execFile } = await import('node:child_process');
57
+ const [bin, args] = process.platform === 'win32'
58
+ ? ['cmd', ['/c', 'start', '', payload.url]]
59
+ : process.platform === 'darwin'
60
+ ? ['open', [payload.url]]
61
+ : ['xdg-open', [payload.url]];
62
+ // 不 await,也不让打开失败反向影响接口语义;仅记录以便排查
63
+ execFile(bin, args, (err) => {
64
+ if (err)
65
+ console.warn('[dsh-github-flow] 打开浏览器失败:', err?.message || err);
66
+ });
67
+ sendJson(res, 200, { ok: true, message: '已在浏览器打开' });
68
+ break;
69
+ }
70
+ case 'open_terminal': {
71
+ const command = typeof payload.command === 'string' ? payload.command.trim() : 'gh auth login';
72
+ // 严格白名单过滤,严防任意命令注入
73
+ const allowedCommands = [
74
+ 'gh auth login',
75
+ 'winget install --id GitHub.cli',
76
+ 'brew install gh',
77
+ 'sudo apt install gh',
78
+ 'sudo dnf install gh',
79
+ ];
80
+ if (!allowedCommands.includes(command)) {
81
+ throw new Error(`不支持唤起该指令: ${command}`);
82
+ }
83
+ const { execFile } = await import('node:child_process');
84
+ let bin = 'cmd';
85
+ let args = [];
86
+ if (process.platform === 'win32') {
87
+ bin = 'cmd';
88
+ args = ['/c', 'start', 'powershell', '-NoExit', '-Command', command];
89
+ }
90
+ else if (process.platform === 'darwin') {
91
+ bin = 'osascript';
92
+ args = ['-e', `tell application "Terminal" to do script "${command}"`, '-e', 'tell application "Terminal" to activate'];
93
+ }
94
+ else {
95
+ bin = 'x-terminal-emulator';
96
+ args = ['-e', command];
97
+ }
98
+ execFile(bin, args, (err) => {
99
+ if (err)
100
+ console.warn('[dsh-github-flow] 唤起终端失败:', err?.message || err);
101
+ });
102
+ sendJson(res, 200, { ok: true, message: '已请求唤起系统终端' });
103
+ break;
104
+ }
105
+ default:
106
+ sendJson(res, 400, { ok: false, message: `未知的操作类型: ${payload.action}` });
107
+ }
108
+ }
109
+ catch (err) {
110
+ sendJson(res, 500, { ok: false, error: err.message || '操作执行失败' });
111
+ }
112
+ });
113
+ },
114
+ });
115
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * 生命周期类路由:版本检查 / 一键升级 / 升级状态轮询 / 认证轻量探针。
3
+ * 共同特征:服务插件自身状态而非 GitHub 业务数据,故障语义独立于概览链路。
4
+ */
5
+ import type { GhExecutor } from '../executor.js';
6
+ import type { UpdateChecker } from '../updater.js';
7
+ import type { UpgradeManager } from '../upgrade-manager.js';
8
+ import { type RouteDeps } from './route-helpers.js';
9
+ export declare function registerLifecycleRoutes(deps: RouteDeps, executor: GhExecutor, checker: UpdateChecker, upgrader: UpgradeManager): void;
@@ -0,0 +1,75 @@
1
+ import { sendJson } from './route-helpers.js';
2
+ export function registerLifecycleRoutes(deps, executor, checker, upgrader) {
3
+ const { bindRoute } = deps;
4
+ // 1. 闲时静默检查更新端点(前端在 gh 业务数据完全拉取就绪后,由 idle 延时静默触发)
5
+ bindRoute({
6
+ kind: 'exact',
7
+ path: '/api/github/check-update',
8
+ handler: async (req, res) => {
9
+ if (req.method !== 'GET') {
10
+ sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
11
+ return;
12
+ }
13
+ try {
14
+ const url = new URL(req.url || '', 'http://127.0.0.1');
15
+ const force = url.searchParams.get('force') === 'true';
16
+ const info = await checker.check(force);
17
+ sendJson(res, 200, { ok: true, data: info });
18
+ }
19
+ catch (err) {
20
+ sendJson(res, 500, { ok: false, error: err.message || '检查更新失败' });
21
+ }
22
+ },
23
+ });
24
+ // 2. 一键自动升级触发端点(用户点击操作横幅按钮后发起)
25
+ bindRoute({
26
+ kind: 'exact',
27
+ path: '/api/github/upgrade',
28
+ handler: async (req, res) => {
29
+ if (req.method !== 'POST') {
30
+ sendJson(res, 405, { ok: false, message: '仅支持 POST 请求' });
31
+ return;
32
+ }
33
+ try {
34
+ const info = await checker.check(false);
35
+ const result = await upgrader.executeUpgrade(info.latestVersion);
36
+ sendJson(res, result.ok ? 200 : 500, result);
37
+ }
38
+ catch (err) {
39
+ sendJson(res, 500, { ok: false, message: err.message || '执行自动升级异常' });
40
+ }
41
+ },
42
+ });
43
+ // 3. 升级状态与实时日志查询端点
44
+ bindRoute({
45
+ kind: 'exact',
46
+ path: '/api/github/upgrade-status',
47
+ handler: async (req, res) => {
48
+ if (req.method !== 'GET') {
49
+ sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
50
+ return;
51
+ }
52
+ sendJson(res, 200, { ok: true, data: upgrader.getStatus() });
53
+ },
54
+ });
55
+ // 4. 快速认证状态轻量探针 (耗时 < 500ms,专供登录自愈检测与快速恢复)
56
+ bindRoute({
57
+ kind: 'exact',
58
+ path: '/api/github/auth',
59
+ handler: async (req, res) => {
60
+ if (req.method !== 'GET') {
61
+ sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
62
+ return;
63
+ }
64
+ try {
65
+ const url = new URL(req.url || '', 'http://127.0.0.1');
66
+ const force = url.searchParams.get('force') === 'true';
67
+ const auth = await executor.checkAuth(undefined, force);
68
+ sendJson(res, 200, { ok: true, auth });
69
+ }
70
+ catch (err) {
71
+ sendJson(res, 500, { ok: false, error: err.message || '认证检测失败' });
72
+ }
73
+ },
74
+ });
75
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 概览类路由:单仓库 overview + 全局驾驶舱 global-overview。
3
+ * 这两个端点是面板首屏关键路径(也是项目里最重的两个 handler),单独成组
4
+ * 便于按「读密集」特征统一演进(缓存策略、字段投影、并发控制)。
5
+ */
6
+ import type { GhExecutor } from '../executor.js';
7
+ import { TtlCache } from '../cache.js';
8
+ import type { UpdateChecker } from '../updater.js';
9
+ import type { GitHubOverviewData, GlobalOverviewData } from '../types.js';
10
+ import { type RouteDeps } from './route-helpers.js';
11
+ export declare function registerOverviewRoutes(deps: RouteDeps, executor: GhExecutor, cacheMap: TtlCache<GitHubOverviewData>, globalCache: TtlCache<GlobalOverviewData>, checker: UpdateChecker): void;
@@ -0,0 +1,227 @@
1
+ import { mapLimit, tryReadWorkspaces, findWorkspacePathBySession, findMostRecentWorkspacePath, hasGitDir } from '../workspace.js';
2
+ import { resolveIssueCategories, calculateIssueStats, basenameSafe, sendJson } from './route-helpers.js';
3
+ /** 探测单个工作区时在途的 gh 进程数上限;libuv 线程池默认 4,留出余量给其他请求 */
4
+ const WORKSPACE_PROBE_CONCURRENCY = 3;
5
+ export function registerOverviewRoutes(deps, executor, cacheMap, globalCache, checker) {
6
+ const { bindRoute } = deps;
7
+ // 1. 获取全局与当前仓库的 GitHub 概览
8
+ bindRoute({
9
+ kind: 'exact',
10
+ path: '/api/github/overview',
11
+ handler: async (req, res) => {
12
+ if (req.method !== 'GET') {
13
+ sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
14
+ return;
15
+ }
16
+ try {
17
+ const url = new URL(req.url || '', 'http://127.0.0.1');
18
+ let cwd = url.searchParams.get('cwd');
19
+ const sessionId = url.searchParams.get('sessionId');
20
+ // 会话 ID → 物理路径,以及「最近活跃工作区」兜底,统一由 workspace 模块提供
21
+ if (!cwd && sessionId) {
22
+ cwd = findWorkspacePathBySession(sessionId) || null;
23
+ }
24
+ if (!cwd) {
25
+ cwd = findMostRecentWorkspacePath() || null;
26
+ }
27
+ if (!cwd) {
28
+ cwd = process.cwd();
29
+ }
30
+ const now = Date.now();
31
+ const cachedData = cacheMap.get(cwd, now);
32
+ if (cachedData) {
33
+ sendJson(res, 200, {
34
+ ok: true,
35
+ data: { ...cachedData, updateInfo: checker.getQuickStatus() },
36
+ fromCache: true,
37
+ cwd,
38
+ });
39
+ return;
40
+ }
41
+ const auth = await executor.checkAuth(cwd);
42
+ const repo = await executor.getRepoMetadata(cwd);
43
+ let pullRequests = [];
44
+ let issues = [];
45
+ let runs = [];
46
+ if (repo && auth.loggedIn) {
47
+ // 并发拉取当前文件夹仓库的 PRs、Issues 和 Runs
48
+ const [prsRes, issuesRes, runsRes] = await Promise.all([
49
+ executor.run(['pr', 'list', '--json', 'number,title,state,author,headRefName,baseRefName,isDraft,mergeable,reviewDecision,statusCheckRollup,url,updatedAt', '-L', '10'], { cwd, timeoutMs: 8_000 }),
50
+ executor.run(['issue', 'list', '--json', 'number,title,state,author,labels,assignees,url,updatedAt', '-L', '10'], { cwd, timeoutMs: 8_000 }),
51
+ executor.run(['run', 'list', '--json', 'databaseId,name,status,conclusion,event,headBranch,url,createdAt', '-L', '5'], { cwd, timeoutMs: 8_000 }),
52
+ ]);
53
+ if (prsRes.ok && Array.isArray(prsRes.data))
54
+ pullRequests = prsRes.data;
55
+ if (issuesRes.ok && Array.isArray(issuesRes.data)) {
56
+ const currentUser = auth.user;
57
+ const repoOwner = repo?.owner || repo?.nameWithOwner?.split('/')[0];
58
+ issues = issuesRes.data.map((item) => {
59
+ const categories = resolveIssueCategories(item, currentUser, repoOwner);
60
+ return {
61
+ ...item,
62
+ categories,
63
+ };
64
+ });
65
+ }
66
+ if (runsRes.ok && Array.isArray(runsRes.data))
67
+ runs = runsRes.data;
68
+ }
69
+ const issueStats = calculateIssueStats(issues);
70
+ const freshData = {
71
+ auth,
72
+ repo: repo || undefined,
73
+ pullRequests,
74
+ issues,
75
+ issueStats,
76
+ runs,
77
+ lastUpdated: new Date().toISOString(),
78
+ updateInfo: checker.getQuickStatus(),
79
+ };
80
+ cacheMap.set(cwd, freshData, now);
81
+ sendJson(res, 200, { ok: true, data: freshData, fromCache: false, cwd });
82
+ }
83
+ catch (err) {
84
+ sendJson(res, 500, { ok: false, error: err.message || '获取 GitHub 概览失败' });
85
+ }
86
+ },
87
+ });
88
+ // 2. 获取全局视角的驾驶舱概览数据 (不绑定单一项目)
89
+ bindRoute({
90
+ kind: 'exact',
91
+ path: '/api/github/global-overview',
92
+ handler: async (req, res) => {
93
+ if (req.method !== 'GET') {
94
+ sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
95
+ return;
96
+ }
97
+ // 全局驾驶舱要 spawn 十几次 gh(认证 + 仓库 + PR + Issue + 每个工作区探测),
98
+ // 实测冷启动曾达 18.9 秒。缓存是这里唯一的解药:面板每次挂载都会来取一次。
99
+ const now = Date.now();
100
+ const cachedGlobal = globalCache.get('global', now);
101
+ if (cachedGlobal) {
102
+ sendJson(res, 200, {
103
+ ok: true,
104
+ data: { ...cachedGlobal, updateInfo: checker.getQuickStatus() },
105
+ fromCache: true,
106
+ });
107
+ return;
108
+ }
109
+ try {
110
+ const auth = await executor.checkAuth();
111
+ let userRepos = [];
112
+ let myPrs = [];
113
+ let myIssues = [];
114
+ let issueStats = { total: 0, reported: 0, assigned: 0, created: 0 };
115
+ let workspaceMatrix = [];
116
+ let workspaceError;
117
+ if (auth.loggedIn) {
118
+ // 并发执行:1. 账号下仓库列表 2. 个人 PRs 3. 三维 Issue 检索(名下仓库用户提单 + 指派待办 + 我创建的)
119
+ // 为什么三路并发搜索:
120
+ // - --owner=@me: 捕获用户/访客在当前用户所有仓库下提的 Issue (如反馈 Bug、Feature Request),解决外部提单看不见的问题
121
+ // - --assignee=@me: 捕获团队成员或自己指派给当前账号的待办任务 (含跨组织/第三方项目)
122
+ // - --author=@me: 捕获当前账号亲自发起的任务与向外部提起的议题
123
+ // 3 个轻量进程与 repo/prs 同步 spawn,经 OS 并发调度实测仅耗时 3 秒出头,完全满足 8 秒超时门限。
124
+ const issueFields = 'number,title,repository,author,assignees,labels,url,updatedAt';
125
+ const queryResults = await Promise.allSettled([
126
+ executor.run(['repo', 'list', '--limit', '20', '--json', 'name,nameWithOwner,description,defaultBranchRef,isPrivate,stargazerCount,updatedAt,url'], { timeoutMs: 8_000 }),
127
+ executor.run(['search', 'prs', '--author=@me', '--state=open', '--limit', '10', '--json', 'number,title,repository,url,updatedAt'], { timeoutMs: 8_000 }),
128
+ executor.run(['search', 'issues', '--owner=@me', '--state=open', '--sort=updated', '--limit', '15', '--json', issueFields], { timeoutMs: 8_000 }),
129
+ executor.run(['search', 'issues', '--assignee=@me', '--state=open', '--sort=updated', '--limit', '15', '--json', issueFields], { timeoutMs: 8_000 }),
130
+ executor.run(['search', 'issues', '--author=@me', '--state=open', '--sort=updated', '--limit', '15', '--json', issueFields], { timeoutMs: 8_000 }),
131
+ ]);
132
+ const reposRes = queryResults[0].status === 'fulfilled' ? queryResults[0].value : { ok: false };
133
+ const prsRes = queryResults[1].status === 'fulfilled' ? queryResults[1].value : { ok: false };
134
+ const ownerIssuesRes = queryResults[2].status === 'fulfilled' ? queryResults[2].value : { ok: false };
135
+ const assignedIssuesRes = queryResults[3].status === 'fulfilled' ? queryResults[3].value : { ok: false };
136
+ const authorIssuesRes = queryResults[4].status === 'fulfilled' ? queryResults[4].value : { ok: false };
137
+ if (reposRes.ok && Array.isArray(reposRes.data)) {
138
+ userRepos = reposRes.data.map((r) => ({
139
+ name: r.name || '',
140
+ nameWithOwner: r.nameWithOwner || '',
141
+ description: r.description || '',
142
+ defaultBranch: r.defaultBranchRef?.name || 'main',
143
+ isPrivate: Boolean(r.isPrivate),
144
+ stargazerCount: r.stargazerCount || 0,
145
+ updatedAt: r.updatedAt || '',
146
+ url: r.url || `https://github.com/${r.nameWithOwner}`,
147
+ }));
148
+ }
149
+ if (prsRes.ok && Array.isArray(prsRes.data)) {
150
+ myPrs = prsRes.data;
151
+ }
152
+ // 多源 Issues 聚合、按 URL 唯一去重并排序
153
+ const issueMap = new Map();
154
+ const collectIssues = (res) => {
155
+ if (res.ok && Array.isArray(res.data)) {
156
+ for (const item of res.data) {
157
+ if (item.url && !issueMap.has(item.url)) {
158
+ issueMap.set(item.url, item);
159
+ }
160
+ }
161
+ }
162
+ };
163
+ collectIssues(ownerIssuesRes);
164
+ collectIssues(assignedIssuesRes);
165
+ collectIssues(authorIssuesRes);
166
+ const rawList = Array.from(issueMap.values());
167
+ // 统一按最后更新时间降序排列
168
+ rawList.sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime());
169
+ const currentUser = auth.user;
170
+ myIssues = rawList.map((raw) => {
171
+ const categories = resolveIssueCategories(raw, currentUser);
172
+ return {
173
+ number: raw.number,
174
+ title: raw.title || '',
175
+ repository: raw.repository || { nameWithOwner: '' },
176
+ author: raw.author ? { login: raw.author.login } : undefined,
177
+ assignees: Array.isArray(raw.assignees) ? raw.assignees.map((a) => ({ login: a.login })) : [],
178
+ labels: Array.isArray(raw.labels) ? raw.labels.map((l) => ({ name: l.name, color: l.color })) : [],
179
+ url: raw.url || '',
180
+ updatedAt: raw.updatedAt || '',
181
+ state: raw.state || 'OPEN',
182
+ categories,
183
+ };
184
+ });
185
+ issueStats = calculateIssueStats(myIssues);
186
+ }
187
+ // 解析本地所有工作区与 GitHub 关联矩阵
188
+ const wsRead = tryReadWorkspaces();
189
+ workspaceError = wsRead.error;
190
+ if (!workspaceError) {
191
+ const wsEntries = wsRead.workspaces.filter((ws) => ws.path);
192
+ // 有界并发探测:每个工作区最多 spawn 2 个 gh 进程,无上限并发会让它们全挤进
193
+ // libuv 默认 4 线程的队列里排队,延迟随工作区数量线性劣化。
194
+ // 这里刻意只用本地 .git/config 识别身份、不发网络请求——全局矩阵只需要「属于哪个仓库」,
195
+ // 每次网络往返都要秒级,5 个工作区就足以把该端点拖到 20 秒。
196
+ workspaceMatrix = await mapLimit(wsEntries, WORKSPACE_PROBE_CONCURRENCY, async (ws) => {
197
+ const hasGit = hasGitDir(ws.path);
198
+ return {
199
+ id: ws.id,
200
+ title: ws.title || basenameSafe(ws.path),
201
+ path: ws.path,
202
+ hasGit,
203
+ repo: hasGit ? executor.getRepoIdentityFromLocal(ws.path) || undefined : undefined,
204
+ sessionCount: ws.sessionIds.length,
205
+ };
206
+ });
207
+ }
208
+ const globalData = {
209
+ auth,
210
+ userRepos,
211
+ workspaceMatrix,
212
+ myPrs,
213
+ myIssues,
214
+ issueStats,
215
+ lastUpdated: new Date().toISOString(),
216
+ updateInfo: checker.getQuickStatus(),
217
+ ...(workspaceError ? { workspaceError } : {}),
218
+ };
219
+ globalCache.set('global', globalData, now);
220
+ sendJson(res, 200, { ok: true, data: globalData, fromCache: false });
221
+ }
222
+ catch (err) {
223
+ sendJson(res, 500, { ok: false, error: err.message || '获取全局驾驶舱数据失败' });
224
+ }
225
+ },
226
+ });
227
+ }