dsh-github-flow 0.1.7 → 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.
- package/README.en.md +287 -69
- package/README.md +290 -68
- package/docs/architecture.md +71 -14
- package/docs/images/1.png +0 -0
- package/docs/images/2.png +0 -0
- package/docs/images/3.png +0 -0
- package/docs/images/4.png +0 -0
- package/docs/npm-publish-guide.md +12 -0
- package/lib/api/route-helpers.d.ts +47 -0
- package/lib/api/route-helpers.js +134 -0
- package/lib/api/routes-actions.d.ts +9 -0
- package/lib/api/routes-actions.js +115 -0
- package/lib/api/routes-lifecycle.d.ts +9 -0
- package/lib/api/routes-lifecycle.js +75 -0
- package/lib/api/routes-overview.d.ts +11 -0
- package/lib/api/routes-overview.js +227 -0
- package/lib/api/routes-proxy.d.ts +18 -0
- package/lib/api/routes-proxy.js +63 -0
- package/lib/api/routes.d.ts +17 -5
- package/lib/api/routes.js +18 -417
- package/lib/client.js +1179 -40
- package/lib/commands/gh.d.ts +2 -1
- package/lib/commands/gh.js +40 -3
- package/lib/executor.d.ts +17 -0
- package/lib/executor.js +40 -2
- package/lib/main.d.ts +12 -0
- package/lib/main.js +73 -4
- package/lib/proxy.d.ts +87 -0
- package/lib/proxy.js +275 -0
- package/lib/renderers/markdown.d.ts +24 -0
- package/lib/renderers/markdown.js +493 -0
- package/lib/tools/api.d.ts +4 -0
- package/lib/tools/api.js +14 -4
- package/lib/tools/help.d.ts +31 -0
- package/lib/tools/help.js +212 -0
- package/lib/tools/issue.d.ts +32 -1
- package/lib/tools/issue.js +94 -17
- package/lib/tools/pr.d.ts +22 -1
- package/lib/tools/pr.js +67 -15
- package/lib/tools/release.d.ts +89 -0
- package/lib/tools/release.js +219 -0
- package/lib/tools/repo.d.ts +13 -1
- package/lib/tools/repo.js +46 -14
- package/lib/tools/run.d.ts +22 -1
- package/lib/tools/run.js +74 -14
- package/lib/tools/secret.d.ts +60 -0
- package/lib/tools/secret.js +175 -0
- package/lib/tools/skill.d.ts +63 -0
- package/lib/tools/skill.js +146 -0
- package/lib/types.d.ts +57 -0
- package/lib/updater.d.ts +49 -0
- package/lib/updater.js +169 -0
- package/lib/upgrade-manager.d.ts +58 -0
- package/lib/upgrade-manager.js +309 -0
- package/lib/version.d.ts +13 -0
- package/lib/version.js +13 -0
- package/package.json +3 -2
- package/lib/client/icons.d.ts +0 -13
- package/lib/client/icons.js +0 -41
- package/lib/client/index.d.ts +0 -3
- package/lib/client/index.js +0 -36
- package/lib/client/panel.d.ts +0 -1
- package/lib/client/panel.js +0 -102
- package/lib/client/style.d.ts +0 -1
- package/lib/client/style.js +0 -555
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 网络代理设置路由(驾驶舱「网络代理」设置分区的后端入口)。
|
|
3
|
+
* 为什么独立于 Cordis Config:代理设置变更若走 Loader 会触发插件全量 reload
|
|
4
|
+
* (工具注销重注册、面板断流),而代理是高频试错型设置,必须保存即生效。
|
|
5
|
+
*/
|
|
6
|
+
import type { UpdateChecker } from '../updater.js';
|
|
7
|
+
import { type RouteDeps } from './route-helpers.js';
|
|
8
|
+
/** ProxyManager 的路由侧最小接口:避免路由层直接依赖实现细节,测试可注入 mock */
|
|
9
|
+
export interface ProxyRouteManager {
|
|
10
|
+
save: (input: any) => {
|
|
11
|
+
ok: boolean;
|
|
12
|
+
error?: string;
|
|
13
|
+
state: any;
|
|
14
|
+
};
|
|
15
|
+
probe: () => Promise<any>;
|
|
16
|
+
snapshot: () => any;
|
|
17
|
+
}
|
|
18
|
+
export declare function registerProxyRoutes(deps: RouteDeps, checker: UpdateChecker, proxyManager?: ProxyRouteManager): void;
|