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.
- package/README.en.md +139 -54
- package/README.md +142 -54
- package/docs/architecture.md +19 -4
- package/docs/images/1.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 +16 -6
- package/lib/api/routes.js +14 -504
- package/lib/client.js +383 -55
- package/lib/executor.d.ts +17 -0
- package/lib/executor.js +28 -0
- package/lib/main.d.ts +1 -0
- package/lib/main.js +40 -9
- 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 +12 -0
- package/lib/updater.d.ts +8 -0
- package/lib/updater.js +9 -0
- package/lib/upgrade-manager.d.ts +4 -0
- package/lib/upgrade-manager.js +11 -1
- 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,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;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { sendJson, readJsonBody } from './route-helpers.js';
|
|
2
|
+
export function registerProxyRoutes(deps, checker, proxyManager) {
|
|
3
|
+
const { bindRoute } = deps;
|
|
4
|
+
// 1. 代理配置:GET 读取状态 / POST 保存并立即生效(写 proxy.json + 重应用 dispatcher,零插件重载)
|
|
5
|
+
bindRoute({
|
|
6
|
+
kind: 'exact',
|
|
7
|
+
path: '/api/github/proxy',
|
|
8
|
+
handler: async (req, res) => {
|
|
9
|
+
if (req.method === 'GET') {
|
|
10
|
+
if (!proxyManager) {
|
|
11
|
+
sendJson(res, 200, { ok: true, state: null, message: '当前运行环境未启用代理管理器' });
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
sendJson(res, 200, { ok: true, state: proxyManager.snapshot() });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (req.method !== 'POST') {
|
|
18
|
+
sendJson(res, 405, { ok: false, message: '仅支持 GET / POST 请求' });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (!proxyManager) {
|
|
22
|
+
sendJson(res, 503, { ok: false, message: '当前运行环境未启用代理管理器' });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const body = await readJsonBody(req);
|
|
27
|
+
const result = proxyManager.save(body || {});
|
|
28
|
+
if (!result.ok) {
|
|
29
|
+
sendJson(res, 400, { ok: false, message: result.error, state: result.state });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
// 配置变更后同步刷新 updater 的 fetch dispatcher(npm 探测链路)
|
|
33
|
+
checker.applyProxy(proxyManager);
|
|
34
|
+
sendJson(res, 200, { ok: true, state: result.state });
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
sendJson(res, 500, { ok: false, message: err.message || '代理配置保存失败' });
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
// 2. 手动触发连通性探测(2.5s 超时,结果持久化到 lastProbe 供 UI 展示)
|
|
42
|
+
bindRoute({
|
|
43
|
+
kind: 'exact',
|
|
44
|
+
path: '/api/github/proxy/probe',
|
|
45
|
+
handler: async (req, res) => {
|
|
46
|
+
if (req.method !== 'POST') {
|
|
47
|
+
sendJson(res, 405, { ok: false, message: '仅支持 POST 请求' });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (!proxyManager) {
|
|
51
|
+
sendJson(res, 503, { ok: false, message: '当前运行环境未启用代理管理器' });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const probe = await proxyManager.probe();
|
|
56
|
+
sendJson(res, 200, { ok: true, probe, state: proxyManager.snapshot() });
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
sendJson(res, 500, { ok: false, message: err.message || '代理探测失败' });
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
package/lib/api/routes.d.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API 路由注册器(纯装配骨架)。
|
|
3
|
+
*
|
|
4
|
+
* 架构说明:具体 handler 按领域拆分在 routes-overview / routes-actions /
|
|
5
|
+
* routes-lifecycle / routes-proxy 四个分组文件中,本文件只负责:
|
|
6
|
+
* 1. 解析 ctx/webServer 与可选依赖(缺省时给默认实现,保持向后兼容);
|
|
7
|
+
* 2. 构造共享闭包(缓存实例 + bindRoute 生命周期绑定);
|
|
8
|
+
* 3. 按固定顺序装配各分组。
|
|
9
|
+
*
|
|
10
|
+
* 为什么 bindRoute 必须在这里定义:它闭包引用 ctx(effect 纳管)与 webServer
|
|
11
|
+
* (自愈清理),分组文件只消费注入的 RouteDeps,不感知 Cordis 上下文。
|
|
12
|
+
*/
|
|
1
13
|
import type { GhExecutor } from '../executor.js';
|
|
2
14
|
import { UpdateChecker } from '../updater.js';
|
|
3
15
|
import { UpgradeManager } from '../upgrade-manager.js';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export declare function globalCacheTtlMs(cacheTtlMs: number): number;
|
|
9
|
-
export declare function registerApiRoutes(ctxOrWebServer: any, executor: GhExecutor, workspaceRegistry?: any, cacheTtlMs?: number, updateChecker?: UpdateChecker, upgradeManager?: UpgradeManager): void;
|
|
16
|
+
import { globalCacheTtlMs } from './route-helpers.js';
|
|
17
|
+
import { type ProxyRouteManager } from './routes-proxy.js';
|
|
18
|
+
export { globalCacheTtlMs };
|
|
19
|
+
export declare function registerApiRoutes(ctxOrWebServer: any, executor: GhExecutor, workspaceRegistry?: any, cacheTtlMs?: number, updateChecker?: UpdateChecker, upgradeManager?: UpgradeManager, proxyManager?: ProxyRouteManager): void;
|
package/lib/api/routes.js
CHANGED
|
@@ -1,102 +1,14 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
1
|
import { TtlCache } from '../cache.js';
|
|
3
|
-
import { mapLimit, tryReadWorkspaces, findWorkspacePathBySession, findMostRecentWorkspacePath, hasGitDir } from '../workspace.js';
|
|
4
2
|
import { UpdateChecker } from '../updater.js';
|
|
5
3
|
import { UpgradeManager } from '../upgrade-manager.js';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
* 同时还要处理他人指派的待办任务。这三个维度互补且可能重叠。
|
|
15
|
-
*/
|
|
16
|
-
function resolveIssueCategories(issue, currentUser, explicitRepoOwner) {
|
|
17
|
-
const categories = [];
|
|
18
|
-
if (!currentUser)
|
|
19
|
-
return categories;
|
|
20
|
-
const currentLower = currentUser.toLowerCase();
|
|
21
|
-
const authorLogin = issue.author?.login?.toLowerCase();
|
|
22
|
-
const isAuthor = Boolean(authorLogin && authorLogin === currentLower);
|
|
23
|
-
const isAssignee = Array.isArray(issue.assignees) &&
|
|
24
|
-
issue.assignees.some((a) => a.login?.toLowerCase() === currentLower);
|
|
25
|
-
// 仓库 owner 判定:优先从 repository.nameWithOwner 提取,兜底使用 explicitRepoOwner
|
|
26
|
-
let repoOwner = explicitRepoOwner?.toLowerCase();
|
|
27
|
-
if (!repoOwner && issue.repository?.nameWithOwner) {
|
|
28
|
-
repoOwner = issue.repository.nameWithOwner.split('/')[0]?.toLowerCase();
|
|
29
|
-
}
|
|
30
|
-
const isOwnerRepo = Boolean(repoOwner && repoOwner === currentLower);
|
|
31
|
-
if (isAuthor)
|
|
32
|
-
categories.push('created');
|
|
33
|
-
if (isAssignee)
|
|
34
|
-
categories.push('assigned');
|
|
35
|
-
// 如果是当前用户维护的仓库,且作者不是本人,则明确归为「用户提起的」
|
|
36
|
-
if (isOwnerRepo && !isAuthor)
|
|
37
|
-
categories.push('reported');
|
|
38
|
-
return categories;
|
|
39
|
-
}
|
|
40
|
-
function calculateIssueStats(issues) {
|
|
41
|
-
let reported = 0;
|
|
42
|
-
let assigned = 0;
|
|
43
|
-
let created = 0;
|
|
44
|
-
for (const item of issues) {
|
|
45
|
-
const cats = item.categories || [];
|
|
46
|
-
if (cats.includes('reported'))
|
|
47
|
-
reported++;
|
|
48
|
-
if (cats.includes('assigned'))
|
|
49
|
-
assigned++;
|
|
50
|
-
if (cats.includes('created'))
|
|
51
|
-
created++;
|
|
52
|
-
}
|
|
53
|
-
return {
|
|
54
|
-
total: issues.length,
|
|
55
|
-
reported,
|
|
56
|
-
assigned,
|
|
57
|
-
created,
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
/** path.basename 在空串/异常输入上会抛错,工作区路径来自磁盘文件,必须防御 */
|
|
61
|
-
function basenameSafe(p) {
|
|
62
|
-
try {
|
|
63
|
-
return path.basename(p) || p;
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
return p;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
/** 探测单个工作区时在途的 gh 进程数上限;libuv 线程池默认 4,留出余量给其他请求 */
|
|
70
|
-
const WORKSPACE_PROBE_CONCURRENCY = 3;
|
|
71
|
-
/**
|
|
72
|
-
* 全局驾驶舱的缓存时长下限。
|
|
73
|
-
* 这个端点要 spawn 十几次 gh,比单仓库概览重得多,因此不直接沿用 cacheTtlMs(默认 15s)。
|
|
74
|
-
* 定在 5 分钟的依据:它展示的是「我的仓库 / 待办 PR / Issues」这类分钟级不变的数据,
|
|
75
|
-
* 而每次刷新本机实测要 8-13 秒。缓存太短等于让用户反复等这段延迟——实测 60 秒缓存下,
|
|
76
|
-
* 用户 65 秒后再打开面板仍需等 9.7 秒。仍随 cacheTtlMs 缩放,用户调大时这里同步变长。
|
|
77
|
-
*/
|
|
78
|
-
const GLOBAL_CACHE_MIN_TTL_MS = 300_000;
|
|
79
|
-
/**
|
|
80
|
-
* 由用户配置推导全局驾驶舱的缓存时长。
|
|
81
|
-
* 抽成导出的纯函数:TTL 的推导规则是测试断言的对象,埋在闭包里就只能靠猜。
|
|
82
|
-
*/
|
|
83
|
-
export function globalCacheTtlMs(cacheTtlMs) {
|
|
84
|
-
const base = Number.isFinite(cacheTtlMs) && cacheTtlMs >= 0 ? cacheTtlMs : 15_000;
|
|
85
|
-
return Math.max(base * 20, GLOBAL_CACHE_MIN_TTL_MS);
|
|
86
|
-
}
|
|
87
|
-
/** open_browser 只允许 http/https,杜绝 file:// 与自定义协议被当作打开目标 */
|
|
88
|
-
function isSafeExternalUrl(raw) {
|
|
89
|
-
if (typeof raw !== 'string' || raw.length === 0 || raw.length > 2048)
|
|
90
|
-
return false;
|
|
91
|
-
try {
|
|
92
|
-
const parsed = new URL(raw);
|
|
93
|
-
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
94
|
-
}
|
|
95
|
-
catch {
|
|
96
|
-
return false;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
export function registerApiRoutes(ctxOrWebServer, executor, workspaceRegistry, cacheTtlMs = 15_000, updateChecker, upgradeManager) {
|
|
4
|
+
import { globalCacheTtlMs, sendJson, readJsonBody } from './route-helpers.js';
|
|
5
|
+
import { registerOverviewRoutes } from './routes-overview.js';
|
|
6
|
+
import { registerActionRoutes } from './routes-actions.js';
|
|
7
|
+
import { registerLifecycleRoutes } from './routes-lifecycle.js';
|
|
8
|
+
import { registerProxyRoutes } from './routes-proxy.js';
|
|
9
|
+
// 再导出历史公共接口:globalCacheTtlMs 曾被测试直接 import,保持路径稳定避免破坏消费方
|
|
10
|
+
export { globalCacheTtlMs };
|
|
11
|
+
export function registerApiRoutes(ctxOrWebServer, executor, workspaceRegistry, cacheTtlMs = 15_000, updateChecker, upgradeManager, proxyManager) {
|
|
100
12
|
const ctx = ctxOrWebServer;
|
|
101
13
|
const webServer = ctx?.webServer || ctx;
|
|
102
14
|
if (!webServer || typeof webServer.register !== 'function')
|
|
@@ -105,12 +17,6 @@ export function registerApiRoutes(ctxOrWebServer, executor, workspaceRegistry, c
|
|
|
105
17
|
const upgrader = upgradeManager || new UpgradeManager();
|
|
106
18
|
const cacheMap = new TtlCache(cacheTtlMs);
|
|
107
19
|
const globalCache = new TtlCache(globalCacheTtlMs(cacheTtlMs));
|
|
108
|
-
function sendJson(res, status, data) {
|
|
109
|
-
res.statusCode = status;
|
|
110
|
-
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
111
|
-
res.setHeader('Cache-Control', 'no-store');
|
|
112
|
-
res.end(JSON.stringify(data));
|
|
113
|
-
}
|
|
114
20
|
/**
|
|
115
21
|
* 注册单个路由并返回注销器。
|
|
116
22
|
* 为什么需要前置探测与清理:Cordis 插件在经历热重载、异常崩溃或重新启用时,若先前的 disposer
|
|
@@ -140,406 +46,10 @@ export function registerApiRoutes(ctxOrWebServer, executor, workspaceRegistry, c
|
|
|
140
46
|
registerOneRoute(route);
|
|
141
47
|
}
|
|
142
48
|
};
|
|
143
|
-
//
|
|
144
|
-
bindRoute
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
try {
|
|
153
|
-
const url = new URL(req.url || '', 'http://127.0.0.1');
|
|
154
|
-
let cwd = url.searchParams.get('cwd');
|
|
155
|
-
const sessionId = url.searchParams.get('sessionId');
|
|
156
|
-
// 会话 ID → 物理路径,以及「最近活跃工作区」兜底,统一由 workspace 模块提供
|
|
157
|
-
if (!cwd && sessionId) {
|
|
158
|
-
cwd = findWorkspacePathBySession(sessionId) || null;
|
|
159
|
-
}
|
|
160
|
-
if (!cwd) {
|
|
161
|
-
cwd = findMostRecentWorkspacePath() || null;
|
|
162
|
-
}
|
|
163
|
-
if (!cwd) {
|
|
164
|
-
cwd = process.cwd();
|
|
165
|
-
}
|
|
166
|
-
const now = Date.now();
|
|
167
|
-
const cachedData = cacheMap.get(cwd, now);
|
|
168
|
-
if (cachedData) {
|
|
169
|
-
sendJson(res, 200, {
|
|
170
|
-
ok: true,
|
|
171
|
-
data: { ...cachedData, updateInfo: checker.getQuickStatus() },
|
|
172
|
-
fromCache: true,
|
|
173
|
-
cwd,
|
|
174
|
-
});
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
const auth = await executor.checkAuth(cwd);
|
|
178
|
-
const repo = await executor.getRepoMetadata(cwd);
|
|
179
|
-
let pullRequests = [];
|
|
180
|
-
let issues = [];
|
|
181
|
-
let runs = [];
|
|
182
|
-
if (repo && auth.loggedIn) {
|
|
183
|
-
// 并发拉取当前文件夹仓库的 PRs、Issues 和 Runs
|
|
184
|
-
const [prsRes, issuesRes, runsRes] = await Promise.all([
|
|
185
|
-
executor.run(['pr', 'list', '--json', 'number,title,state,author,headRefName,baseRefName,isDraft,mergeable,reviewDecision,statusCheckRollup,url,updatedAt', '-L', '10'], { cwd, timeoutMs: 8_000 }),
|
|
186
|
-
executor.run(['issue', 'list', '--json', 'number,title,state,author,labels,assignees,url,updatedAt', '-L', '10'], { cwd, timeoutMs: 8_000 }),
|
|
187
|
-
executor.run(['run', 'list', '--json', 'databaseId,name,status,conclusion,event,headBranch,url,createdAt', '-L', '5'], { cwd, timeoutMs: 8_000 }),
|
|
188
|
-
]);
|
|
189
|
-
if (prsRes.ok && Array.isArray(prsRes.data))
|
|
190
|
-
pullRequests = prsRes.data;
|
|
191
|
-
if (issuesRes.ok && Array.isArray(issuesRes.data)) {
|
|
192
|
-
const currentUser = auth.user;
|
|
193
|
-
const repoOwner = repo?.owner || repo?.nameWithOwner?.split('/')[0];
|
|
194
|
-
issues = issuesRes.data.map((item) => {
|
|
195
|
-
const categories = resolveIssueCategories(item, currentUser, repoOwner);
|
|
196
|
-
return {
|
|
197
|
-
...item,
|
|
198
|
-
categories,
|
|
199
|
-
};
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
if (runsRes.ok && Array.isArray(runsRes.data))
|
|
203
|
-
runs = runsRes.data;
|
|
204
|
-
}
|
|
205
|
-
const issueStats = calculateIssueStats(issues);
|
|
206
|
-
const freshData = {
|
|
207
|
-
auth,
|
|
208
|
-
repo: repo || undefined,
|
|
209
|
-
pullRequests,
|
|
210
|
-
issues,
|
|
211
|
-
issueStats,
|
|
212
|
-
runs,
|
|
213
|
-
lastUpdated: new Date().toISOString(),
|
|
214
|
-
updateInfo: checker.getQuickStatus(),
|
|
215
|
-
};
|
|
216
|
-
cacheMap.set(cwd, freshData, now);
|
|
217
|
-
sendJson(res, 200, { ok: true, data: freshData, fromCache: false, cwd });
|
|
218
|
-
}
|
|
219
|
-
catch (err) {
|
|
220
|
-
sendJson(res, 500, { ok: false, error: err.message || '获取 GitHub 概览失败' });
|
|
221
|
-
}
|
|
222
|
-
},
|
|
223
|
-
});
|
|
224
|
-
// 2. 强制刷新缓存
|
|
225
|
-
bindRoute({
|
|
226
|
-
kind: 'exact',
|
|
227
|
-
path: '/api/github/refresh',
|
|
228
|
-
handler: async (req, res) => {
|
|
229
|
-
cacheMap.clear();
|
|
230
|
-
globalCache.clear();
|
|
231
|
-
sendJson(res, 200, { ok: true, message: '缓存已清空,下次读取将拉取最新数据' });
|
|
232
|
-
},
|
|
233
|
-
});
|
|
234
|
-
// 3. 右侧栏快捷操作代理接口
|
|
235
|
-
bindRoute({
|
|
236
|
-
kind: 'exact',
|
|
237
|
-
path: '/api/github/action',
|
|
238
|
-
handler: async (req, res) => {
|
|
239
|
-
if (req.method !== 'POST') {
|
|
240
|
-
sendJson(res, 405, { ok: false, message: '仅支持 POST 请求' });
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
let body = '';
|
|
244
|
-
req.on('data', (chunk) => { body += chunk; });
|
|
245
|
-
req.on('end', async () => {
|
|
246
|
-
try {
|
|
247
|
-
const payload = body ? JSON.parse(body) : {};
|
|
248
|
-
const cwd = payload.cwd || process.cwd();
|
|
249
|
-
switch (payload.action) {
|
|
250
|
-
case 'create_pr': {
|
|
251
|
-
const cmd = ['pr', 'create', '--title', payload.title || 'Update', '--body', payload.body || 'Created from DSH Rightbar'];
|
|
252
|
-
if (payload.draft)
|
|
253
|
-
cmd.push('--draft');
|
|
254
|
-
const r = await executor.run(cmd, { cwd });
|
|
255
|
-
if (!r.ok)
|
|
256
|
-
throw new Error(r.error);
|
|
257
|
-
sendJson(res, 200, { ok: true, message: 'PR 创建成功', url: r.rawOutput });
|
|
258
|
-
break;
|
|
259
|
-
}
|
|
260
|
-
case 'create_issue': {
|
|
261
|
-
const cmd = ['issue', 'create', '--title', payload.title || 'New Issue', '--body', payload.body || 'Created from DSH Rightbar'];
|
|
262
|
-
const r = await executor.run(cmd, { cwd });
|
|
263
|
-
if (!r.ok)
|
|
264
|
-
throw new Error(r.error);
|
|
265
|
-
sendJson(res, 200, { ok: true, message: 'Issue 创建成功', url: r.rawOutput });
|
|
266
|
-
break;
|
|
267
|
-
}
|
|
268
|
-
case 'open_browser': {
|
|
269
|
-
// 原实现把 payload.url 直接拼进 shell 字符串再交给 exec(),
|
|
270
|
-
// 形如 https://x" & calc & " 的 URL 可闭合引号并追加任意命令——本机任意页面
|
|
271
|
-
// 都能通过 POST 这个端点触发命令执行。
|
|
272
|
-
// 现在改为:先用 URL 解析白名单协议,再用 execFile 参数数组传递,全程不经过 shell。
|
|
273
|
-
if (!isSafeExternalUrl(payload.url)) {
|
|
274
|
-
throw new Error('url 参数无效:只允许 http/https 开头的完整地址');
|
|
275
|
-
}
|
|
276
|
-
const { execFile } = await import('node:child_process');
|
|
277
|
-
const [bin, args] = process.platform === 'win32'
|
|
278
|
-
? ['cmd', ['/c', 'start', '', payload.url]]
|
|
279
|
-
: process.platform === 'darwin'
|
|
280
|
-
? ['open', [payload.url]]
|
|
281
|
-
: ['xdg-open', [payload.url]];
|
|
282
|
-
// 不 await,也不让打开失败反向影响接口语义;仅记录以便排查
|
|
283
|
-
execFile(bin, args, (err) => {
|
|
284
|
-
if (err)
|
|
285
|
-
console.warn('[dsh-github-flow] 打开浏览器失败:', err?.message || err);
|
|
286
|
-
});
|
|
287
|
-
sendJson(res, 200, { ok: true, message: '已在浏览器打开' });
|
|
288
|
-
break;
|
|
289
|
-
}
|
|
290
|
-
case 'open_terminal': {
|
|
291
|
-
const command = typeof payload.command === 'string' ? payload.command.trim() : 'gh auth login';
|
|
292
|
-
// 严格白名单过滤,严防任意命令注入
|
|
293
|
-
const allowedCommands = [
|
|
294
|
-
'gh auth login',
|
|
295
|
-
'winget install --id GitHub.cli',
|
|
296
|
-
'brew install gh',
|
|
297
|
-
'sudo apt install gh',
|
|
298
|
-
'sudo dnf install gh',
|
|
299
|
-
];
|
|
300
|
-
if (!allowedCommands.includes(command)) {
|
|
301
|
-
throw new Error(`不支持唤起该指令: ${command}`);
|
|
302
|
-
}
|
|
303
|
-
const { execFile } = await import('node:child_process');
|
|
304
|
-
let bin = 'cmd';
|
|
305
|
-
let args = [];
|
|
306
|
-
if (process.platform === 'win32') {
|
|
307
|
-
bin = 'cmd';
|
|
308
|
-
args = ['/c', 'start', 'powershell', '-NoExit', '-Command', command];
|
|
309
|
-
}
|
|
310
|
-
else if (process.platform === 'darwin') {
|
|
311
|
-
bin = 'osascript';
|
|
312
|
-
args = ['-e', `tell application "Terminal" to do script "${command}"`, '-e', 'tell application "Terminal" to activate'];
|
|
313
|
-
}
|
|
314
|
-
else {
|
|
315
|
-
bin = 'x-terminal-emulator';
|
|
316
|
-
args = ['-e', command];
|
|
317
|
-
}
|
|
318
|
-
execFile(bin, args, (err) => {
|
|
319
|
-
if (err)
|
|
320
|
-
console.warn('[dsh-github-flow] 唤起终端失败:', err?.message || err);
|
|
321
|
-
});
|
|
322
|
-
sendJson(res, 200, { ok: true, message: '已请求唤起系统终端' });
|
|
323
|
-
break;
|
|
324
|
-
}
|
|
325
|
-
default:
|
|
326
|
-
sendJson(res, 400, { ok: false, message: `未知的操作类型: ${payload.action}` });
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
catch (err) {
|
|
330
|
-
sendJson(res, 500, { ok: false, error: err.message || '操作执行失败' });
|
|
331
|
-
}
|
|
332
|
-
});
|
|
333
|
-
},
|
|
334
|
-
});
|
|
335
|
-
// 4. 获取全局视角的驾驶舱概览数据 (不绑定单一项目)
|
|
336
|
-
bindRoute({
|
|
337
|
-
kind: 'exact',
|
|
338
|
-
path: '/api/github/global-overview',
|
|
339
|
-
handler: async (req, res) => {
|
|
340
|
-
if (req.method !== 'GET') {
|
|
341
|
-
sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
|
|
342
|
-
return;
|
|
343
|
-
}
|
|
344
|
-
// 全局驾驶舱要 spawn 十几次 gh(认证 + 仓库 + PR + Issue + 每个工作区探测),
|
|
345
|
-
// 实测冷启动曾达 18.9 秒。缓存是这里唯一的解药:面板每次挂载都会来取一次。
|
|
346
|
-
const now = Date.now();
|
|
347
|
-
const cachedGlobal = globalCache.get('global', now);
|
|
348
|
-
if (cachedGlobal) {
|
|
349
|
-
sendJson(res, 200, {
|
|
350
|
-
ok: true,
|
|
351
|
-
data: { ...cachedGlobal, updateInfo: checker.getQuickStatus() },
|
|
352
|
-
fromCache: true,
|
|
353
|
-
});
|
|
354
|
-
return;
|
|
355
|
-
}
|
|
356
|
-
try {
|
|
357
|
-
const auth = await executor.checkAuth();
|
|
358
|
-
let userRepos = [];
|
|
359
|
-
let myPrs = [];
|
|
360
|
-
let myIssues = [];
|
|
361
|
-
let issueStats = { total: 0, reported: 0, assigned: 0, created: 0 };
|
|
362
|
-
let workspaceMatrix = [];
|
|
363
|
-
let workspaceError;
|
|
364
|
-
if (auth.loggedIn) {
|
|
365
|
-
// 并发执行:1. 账号下仓库列表 2. 个人 PRs 3. 三维 Issue 检索(名下仓库用户提单 + 指派待办 + 我创建的)
|
|
366
|
-
// 为什么三路并发搜索:
|
|
367
|
-
// - --owner=@me: 捕获用户/访客在当前用户所有仓库下提的 Issue (如反馈 Bug、Feature Request),解决外部提单看不见的问题
|
|
368
|
-
// - --assignee=@me: 捕获团队成员或自己指派给当前账号的待办任务 (含跨组织/第三方项目)
|
|
369
|
-
// - --author=@me: 捕获当前账号亲自发起的任务与向外部提起的议题
|
|
370
|
-
// 3 个轻量进程与 repo/prs 同步 spawn,经 OS 并发调度实测仅耗时 3 秒出头,完全满足 8 秒超时门限。
|
|
371
|
-
const issueFields = 'number,title,repository,author,assignees,labels,url,updatedAt';
|
|
372
|
-
const queryResults = await Promise.allSettled([
|
|
373
|
-
executor.run(['repo', 'list', '--limit', '20', '--json', 'name,nameWithOwner,description,defaultBranchRef,isPrivate,stargazerCount,updatedAt,url'], { timeoutMs: 8_000 }),
|
|
374
|
-
executor.run(['search', 'prs', '--author=@me', '--state=open', '--limit', '10', '--json', 'number,title,repository,url,updatedAt'], { timeoutMs: 8_000 }),
|
|
375
|
-
executor.run(['search', 'issues', '--owner=@me', '--state=open', '--sort=updated', '--limit', '15', '--json', issueFields], { timeoutMs: 8_000 }),
|
|
376
|
-
executor.run(['search', 'issues', '--assignee=@me', '--state=open', '--sort=updated', '--limit', '15', '--json', issueFields], { timeoutMs: 8_000 }),
|
|
377
|
-
executor.run(['search', 'issues', '--author=@me', '--state=open', '--sort=updated', '--limit', '15', '--json', issueFields], { timeoutMs: 8_000 }),
|
|
378
|
-
]);
|
|
379
|
-
const reposRes = queryResults[0].status === 'fulfilled' ? queryResults[0].value : { ok: false };
|
|
380
|
-
const prsRes = queryResults[1].status === 'fulfilled' ? queryResults[1].value : { ok: false };
|
|
381
|
-
const ownerIssuesRes = queryResults[2].status === 'fulfilled' ? queryResults[2].value : { ok: false };
|
|
382
|
-
const assignedIssuesRes = queryResults[3].status === 'fulfilled' ? queryResults[3].value : { ok: false };
|
|
383
|
-
const authorIssuesRes = queryResults[4].status === 'fulfilled' ? queryResults[4].value : { ok: false };
|
|
384
|
-
if (reposRes.ok && Array.isArray(reposRes.data)) {
|
|
385
|
-
userRepos = reposRes.data.map((r) => ({
|
|
386
|
-
name: r.name || '',
|
|
387
|
-
nameWithOwner: r.nameWithOwner || '',
|
|
388
|
-
description: r.description || '',
|
|
389
|
-
defaultBranch: r.defaultBranchRef?.name || 'main',
|
|
390
|
-
isPrivate: Boolean(r.isPrivate),
|
|
391
|
-
stargazerCount: r.stargazerCount || 0,
|
|
392
|
-
updatedAt: r.updatedAt || '',
|
|
393
|
-
url: r.url || `https://github.com/${r.nameWithOwner}`,
|
|
394
|
-
}));
|
|
395
|
-
}
|
|
396
|
-
if (prsRes.ok && Array.isArray(prsRes.data)) {
|
|
397
|
-
myPrs = prsRes.data;
|
|
398
|
-
}
|
|
399
|
-
// 多源 Issues 聚合、按 URL 唯一去重并排序
|
|
400
|
-
const issueMap = new Map();
|
|
401
|
-
const collectIssues = (res) => {
|
|
402
|
-
if (res.ok && Array.isArray(res.data)) {
|
|
403
|
-
for (const item of res.data) {
|
|
404
|
-
if (item.url && !issueMap.has(item.url)) {
|
|
405
|
-
issueMap.set(item.url, item);
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
};
|
|
410
|
-
collectIssues(ownerIssuesRes);
|
|
411
|
-
collectIssues(assignedIssuesRes);
|
|
412
|
-
collectIssues(authorIssuesRes);
|
|
413
|
-
const rawList = Array.from(issueMap.values());
|
|
414
|
-
// 统一按最后更新时间降序排列
|
|
415
|
-
rawList.sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime());
|
|
416
|
-
const currentUser = auth.user;
|
|
417
|
-
myIssues = rawList.map((raw) => {
|
|
418
|
-
const categories = resolveIssueCategories(raw, currentUser);
|
|
419
|
-
return {
|
|
420
|
-
number: raw.number,
|
|
421
|
-
title: raw.title || '',
|
|
422
|
-
repository: raw.repository || { nameWithOwner: '' },
|
|
423
|
-
author: raw.author ? { login: raw.author.login } : undefined,
|
|
424
|
-
assignees: Array.isArray(raw.assignees) ? raw.assignees.map((a) => ({ login: a.login })) : [],
|
|
425
|
-
labels: Array.isArray(raw.labels) ? raw.labels.map((l) => ({ name: l.name, color: l.color })) : [],
|
|
426
|
-
url: raw.url || '',
|
|
427
|
-
updatedAt: raw.updatedAt || '',
|
|
428
|
-
state: raw.state || 'OPEN',
|
|
429
|
-
categories,
|
|
430
|
-
};
|
|
431
|
-
});
|
|
432
|
-
issueStats = calculateIssueStats(myIssues);
|
|
433
|
-
}
|
|
434
|
-
// 解析本地所有工作区与 GitHub 关联矩阵
|
|
435
|
-
const wsRead = tryReadWorkspaces();
|
|
436
|
-
workspaceError = wsRead.error;
|
|
437
|
-
if (!workspaceError) {
|
|
438
|
-
const wsEntries = wsRead.workspaces.filter((ws) => ws.path);
|
|
439
|
-
// 有界并发探测:每个工作区最多 spawn 2 个 gh 进程,无上限并发会让它们全挤进
|
|
440
|
-
// libuv 默认 4 线程的队列里排队,延迟随工作区数量线性劣化。
|
|
441
|
-
// 这里刻意只用本地 .git/config 识别身份、不发网络请求——全局矩阵只需要「属于哪个仓库」,
|
|
442
|
-
// 每次网络往返都要秒级,5 个工作区就足以把该端点拖到 20 秒。
|
|
443
|
-
workspaceMatrix = await mapLimit(wsEntries, WORKSPACE_PROBE_CONCURRENCY, async (ws) => {
|
|
444
|
-
const hasGit = hasGitDir(ws.path);
|
|
445
|
-
return {
|
|
446
|
-
id: ws.id,
|
|
447
|
-
title: ws.title || basenameSafe(ws.path),
|
|
448
|
-
path: ws.path,
|
|
449
|
-
hasGit,
|
|
450
|
-
repo: hasGit ? executor.getRepoIdentityFromLocal(ws.path) || undefined : undefined,
|
|
451
|
-
sessionCount: ws.sessionIds.length,
|
|
452
|
-
};
|
|
453
|
-
});
|
|
454
|
-
}
|
|
455
|
-
const globalData = {
|
|
456
|
-
auth,
|
|
457
|
-
userRepos,
|
|
458
|
-
workspaceMatrix,
|
|
459
|
-
myPrs,
|
|
460
|
-
myIssues,
|
|
461
|
-
issueStats,
|
|
462
|
-
lastUpdated: new Date().toISOString(),
|
|
463
|
-
updateInfo: checker.getQuickStatus(),
|
|
464
|
-
...(workspaceError ? { workspaceError } : {}),
|
|
465
|
-
};
|
|
466
|
-
globalCache.set('global', globalData, now);
|
|
467
|
-
sendJson(res, 200, { ok: true, data: globalData, fromCache: false });
|
|
468
|
-
}
|
|
469
|
-
catch (err) {
|
|
470
|
-
sendJson(res, 500, { ok: false, error: err.message || '获取全局驾驶舱数据失败' });
|
|
471
|
-
}
|
|
472
|
-
},
|
|
473
|
-
});
|
|
474
|
-
// 5. 闲时静默检查更新端点(前端在 gh 业务数据完全拉取就绪后,由 idle 延时静默触发)
|
|
475
|
-
bindRoute({
|
|
476
|
-
kind: 'exact',
|
|
477
|
-
path: '/api/github/check-update',
|
|
478
|
-
handler: async (req, res) => {
|
|
479
|
-
if (req.method !== 'GET') {
|
|
480
|
-
sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
|
|
481
|
-
return;
|
|
482
|
-
}
|
|
483
|
-
try {
|
|
484
|
-
const url = new URL(req.url || '', 'http://127.0.0.1');
|
|
485
|
-
const force = url.searchParams.get('force') === 'true';
|
|
486
|
-
const info = await checker.check(force);
|
|
487
|
-
sendJson(res, 200, { ok: true, data: info });
|
|
488
|
-
}
|
|
489
|
-
catch (err) {
|
|
490
|
-
sendJson(res, 500, { ok: false, error: err.message || '检查更新失败' });
|
|
491
|
-
}
|
|
492
|
-
},
|
|
493
|
-
});
|
|
494
|
-
// 6. 一键自动升级触发端点(用户点击操作横幅按钮后发起)
|
|
495
|
-
bindRoute({
|
|
496
|
-
kind: 'exact',
|
|
497
|
-
path: '/api/github/upgrade',
|
|
498
|
-
handler: async (req, res) => {
|
|
499
|
-
if (req.method !== 'POST') {
|
|
500
|
-
sendJson(res, 405, { ok: false, message: '仅支持 POST 请求' });
|
|
501
|
-
return;
|
|
502
|
-
}
|
|
503
|
-
try {
|
|
504
|
-
const info = await checker.check(false);
|
|
505
|
-
const result = await upgrader.executeUpgrade(info.latestVersion);
|
|
506
|
-
sendJson(res, result.ok ? 200 : 500, result);
|
|
507
|
-
}
|
|
508
|
-
catch (err) {
|
|
509
|
-
sendJson(res, 500, { ok: false, message: err.message || '执行自动升级异常' });
|
|
510
|
-
}
|
|
511
|
-
},
|
|
512
|
-
});
|
|
513
|
-
// 7. 升级状态与实时日志查询端点
|
|
514
|
-
bindRoute({
|
|
515
|
-
kind: 'exact',
|
|
516
|
-
path: '/api/github/upgrade-status',
|
|
517
|
-
handler: async (req, res) => {
|
|
518
|
-
if (req.method !== 'GET') {
|
|
519
|
-
sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
|
|
520
|
-
return;
|
|
521
|
-
}
|
|
522
|
-
sendJson(res, 200, { ok: true, data: upgrader.getStatus() });
|
|
523
|
-
},
|
|
524
|
-
});
|
|
525
|
-
// 8. 快速认证状态轻量探针 (耗时 < 500ms,专供登录自愈检测与快速恢复)
|
|
526
|
-
bindRoute({
|
|
527
|
-
kind: 'exact',
|
|
528
|
-
path: '/api/github/auth',
|
|
529
|
-
handler: async (req, res) => {
|
|
530
|
-
if (req.method !== 'GET') {
|
|
531
|
-
sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
|
|
532
|
-
return;
|
|
533
|
-
}
|
|
534
|
-
try {
|
|
535
|
-
const url = new URL(req.url || '', 'http://127.0.0.1');
|
|
536
|
-
const force = url.searchParams.get('force') === 'true';
|
|
537
|
-
const auth = await executor.checkAuth(undefined, force);
|
|
538
|
-
sendJson(res, 200, { ok: true, auth });
|
|
539
|
-
}
|
|
540
|
-
catch (err) {
|
|
541
|
-
sendJson(res, 500, { ok: false, error: err.message || '认证检测失败' });
|
|
542
|
-
}
|
|
543
|
-
},
|
|
544
|
-
});
|
|
49
|
+
// 各分组共享的依赖包:分组内部不再触碰 ctx/webServer 全局
|
|
50
|
+
const deps = { bindRoute, sendJson, readJsonBody };
|
|
51
|
+
registerOverviewRoutes(deps, executor, cacheMap, globalCache, checker);
|
|
52
|
+
registerActionRoutes(deps, executor, cacheMap, globalCache);
|
|
53
|
+
registerLifecycleRoutes(deps, executor, checker, upgrader);
|
|
54
|
+
registerProxyRoutes(deps, checker, proxyManager);
|
|
545
55
|
}
|