dsh-github-flow 0.1.3 → 0.1.5
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 +34 -7
- package/README.md +33 -7
- package/cordis.patch.yml +2 -2
- package/docs/architecture.md +14 -8
- package/docs/images/1.png +0 -0
- package/docs/images/2.png +0 -0
- package/lib/api/routes.d.ts +6 -1
- package/lib/api/routes.js +147 -94
- package/lib/cache.d.ts +21 -0
- package/lib/cache.js +48 -0
- package/lib/client.js +129 -38
- package/lib/commands/gh.js +3 -1
- package/lib/executor.d.ts +38 -1
- package/lib/executor.js +190 -26
- package/lib/github-service.d.ts +26 -0
- package/lib/github-service.js +1 -0
- package/lib/index.d.ts +2 -4
- package/lib/index.js +2 -30
- package/lib/main.d.ts +19 -1
- package/lib/main.js +42 -6
- package/lib/tools/api.d.ts +4 -0
- package/lib/tools/api.js +23 -4
- package/lib/tools/pr.d.ts +12 -0
- package/lib/tools/pr.js +30 -3
- package/lib/tools/repo.js +8 -2
- package/lib/types.d.ts +4 -0
- package/lib/workspace.d.ts +43 -0
- package/lib/workspace.js +136 -0
- package/package.json +2 -1
package/README.en.md
CHANGED
|
@@ -22,21 +22,41 @@
|
|
|
22
22
|
|
|
23
23
|
1. **Zero-Configuration Authentication**:
|
|
24
24
|
- Seamlessly reuses existing host credentials from `gh auth login` (OAuth tokens / system Keyrings) without exposing plain-text Personal Access Tokens (PATs) in DSH.
|
|
25
|
-
2. **Deep Workspace Affinity
|
|
26
|
-
- Automatically scopes commands to the active session workspace
|
|
25
|
+
2. **Deep Workspace Affinity & Zero-Config Detection**:
|
|
26
|
+
- Automatically scopes commands to the active session workspace by natively reading local `.git/config` and mapping DSH `workspace.json`. Neither models nor humans need to supply redundant `owner/repo` arguments.
|
|
27
27
|
3. **High Signal-to-Noise Ratio & Token Budget Protection**:
|
|
28
28
|
- Consolidates over 70+ scattered micro-tools into **5 focused domain tools**.
|
|
29
29
|
- Queries use `--json <fields>` projections by default; long diffs and failed CI logs are safely truncated (default 24KB) to protect the model's context window.
|
|
30
|
-
|
|
30
|
+
- All tool outputs are defensively normalized into root objects to strictly comply with DSH Tool Gateway schema validation.
|
|
31
|
+
4. **Dual Native Web UI Views & Dynamic Dual-Theme Support**:
|
|
31
32
|
- **Global Cockpit** (`sidebar.panellist`, Order 20): Account-wide repository overview, local workspace matrix, and pending personal PRs/issues.
|
|
32
|
-
- **Right Sidebar Panel** (`sidebarRightTabs`, Order 40): Shows current repository status, PR list with CI check pills, and quick action
|
|
33
|
+
- **Right Sidebar Panel** (`sidebarRightTabs`, Order 40): Shows current repository status, PR list with CI check pills, and a 2x2 quick action grid (Open Repo, View PRs, Create PR, New Issue) with zero popup dependencies.
|
|
34
|
+
- **Native Light/Dark Dual-Theme Adaptation**: Deeply aligned with DSH official DSW (DeepSeek Web) design tokens, seamlessly reacting to `body[data-ds-dark-theme]` without lag or flickering.
|
|
33
35
|
5. **Standard Cordis Microkernel Compliance**:
|
|
34
36
|
- Exposes native `GitHubService` (`ctx.github`) for ecosystem extensibility;
|
|
35
|
-
- Strongly typed runtime validation via
|
|
37
|
+
- Strongly typed runtime validation via Standard Schema (`Config`);
|
|
36
38
|
- Domain event broadcasting (`github/pr:create`, `github/issue:create`).
|
|
37
39
|
|
|
38
40
|
---
|
|
39
41
|
|
|
42
|
+
## 📸 UI Previews
|
|
43
|
+
|
|
44
|
+
### Global Cockpit (Main Navigation)
|
|
45
|
+
Overview of account-wide repositories, local workspace matrix mapping, and pending personal PRs/issues:
|
|
46
|
+
|
|
47
|
+
<p align="center">
|
|
48
|
+
<img src="./docs/images/1.png" alt="GitHub Global Cockpit" width="100%" />
|
|
49
|
+
</p>
|
|
50
|
+
|
|
51
|
+
### Session Right Sidebar Panel (In-Session Collaboration)
|
|
52
|
+
Automatically detects current workspace repository, displaying live PR checks, Actions pipelines, and quick action shortcuts:
|
|
53
|
+
|
|
54
|
+
<p align="center">
|
|
55
|
+
<img src="./docs/images/2.png" alt="Session Right Sidebar Panel" width="100%" />
|
|
56
|
+
</p>
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
40
60
|
## 🚀 Installation
|
|
41
61
|
|
|
42
62
|
Run in your DSH terminal to install directly from npm:
|
|
@@ -59,7 +79,7 @@ dsh plugin --profile web add dsh-github-flow
|
|
|
59
79
|
| `github_issue` | `list`, `view`, `create`, `comment`, `close`, `reopen` | Search and view issues, create issues, add comments |
|
|
60
80
|
| `github_run` | `list`, `view`, `log_failed`, `rerun`, `cancel` | Monitor Actions workflows; `log_failed` extracts failure logs for automatic fixes |
|
|
61
81
|
| `github_repo` | `view`, `search_code`, `search_repos` | Query repository metadata, search cross-repo code |
|
|
62
|
-
| `github_api` | Any GitHub REST / GraphQL endpoint | Universal escape hatch for custom queries (supports `--jq`) |
|
|
82
|
+
| `github_api` | Any GitHub REST / GraphQL endpoint | Universal escape hatch for custom queries (supports `--jq`, plus a `raw` response-body mode) |
|
|
63
83
|
|
|
64
84
|
---
|
|
65
85
|
|
|
@@ -83,10 +103,17 @@ Customize options in your `cordis.patch.yml` or profile:
|
|
|
83
103
|
ghPath: 'gh' # Custom path to gh binary
|
|
84
104
|
defaultTimeoutMs: 30000 # Timeout in milliseconds
|
|
85
105
|
maxOutputChars: 24000 # Maximum characters per output (Token guard)
|
|
86
|
-
|
|
106
|
+
maxOutputBytes: 96000 # Hard UTF-8 byte ceiling per output, defaults to maxOutputChars * 4
|
|
107
|
+
cacheTtlMs: 15000 # Single-repo overview cache TTL (ms); the global cockpit caches for 20x this, floor 5 minutes
|
|
87
108
|
defaultListLimit: 20 # Default page size
|
|
88
109
|
```
|
|
89
110
|
|
|
111
|
+
> On Web panel refresh cadence: a global cockpit refresh runs a dozen or so `gh` calls
|
|
112
|
+
> (measured 8-13 s on a local machine), so it caches for 5 minutes and re-fetches when you
|
|
113
|
+
> click "🔄 Refresh global data". The workspace matrix only reads local `.git/config` to
|
|
114
|
+
> identify repository ownership and makes no network requests. The `/gh status` command
|
|
115
|
+
> always bypasses the auth cache, so you see the real state immediately after `gh auth login`.
|
|
116
|
+
|
|
90
117
|
---
|
|
91
118
|
|
|
92
119
|
## 🔌 Ecosystem Service Extension (Cordis Service)
|
package/README.md
CHANGED
|
@@ -22,21 +22,41 @@
|
|
|
22
22
|
|
|
23
23
|
1. **零鉴权配置,开箱即用**:
|
|
24
24
|
- 自动复用宿主机已通过 `gh auth login` 认证的登录凭据(OAuth Token / 系统 Keyring),无需在 DSH 中存储任何明文 GitHub PAT。
|
|
25
|
-
2.
|
|
26
|
-
-
|
|
25
|
+
2. **工作区目录深度亲和与零参数探测 (CWD & Git Auto-Binding)**:
|
|
26
|
+
- 自动绑定当前会话所在的物理工作区目录,原生直读本地 `.git/config` 并映射 `workspace.json`,无需 Agent 或人类手动传参 `owner/repo`,零参数直接识别关联仓库。
|
|
27
27
|
3. **高信噪比与 Token 防溢出治理**:
|
|
28
28
|
- 拒绝 70+ 个碎片化微型工具撑爆上下文,收敛聚合为 **5 大领域核心 Tool**。
|
|
29
29
|
- 所有查询原生采用 `--json <fields>` 按需返回;对 `diff`、`log_failed` 设置安全截断保护(默认 24KB),防止模型上下文窗口溢出崩溃。
|
|
30
|
-
|
|
30
|
+
- 工具层已通过防御性输出包装,列表与标量 100% 契合 DSH 工具网关模式约束。
|
|
31
|
+
4. **原生 Web UI 双重视图与双模式自适应**:
|
|
31
32
|
- **全局驾驶舱**(主导航栏 `sidebar.panellist`,Order 20):透视账号全局仓库、本地工作区矩阵、个人待办 PR 与 Issues;
|
|
32
|
-
- **右侧栏工作区面板**(`sidebarRightTabs`,Order 40
|
|
33
|
+
- **右侧栏工作区面板**(`sidebarRightTabs`,Order 40):常驻呈现当前仓库的 PR 状态、Checks 门禁药丸与 2x2 黄金快捷网格(浏览器打开、查看 PRs、快速创建 PR、新建 Issue),一键直达官方比对与创建页,零弹窗依赖;
|
|
34
|
+
- **全场景双模式原生自适应**:深度契合 DSH 官方 DSW(DeepSeek Web)设计变量规范,通过 `body[data-ds-dark-theme]` 驱动浅色与深色主题实时零延迟平滑切换。
|
|
33
35
|
5. **符合 Cordis 官方微内核架构**:
|
|
34
36
|
- 暴露原生 `GitHubService`(`ctx.github`),允许生态其他插件复用;
|
|
35
|
-
-
|
|
37
|
+
- 具备符合 Standard Schema 规范的运行时配置校验(`Config`);
|
|
36
38
|
- 支持领域事件系统(`github/pr:create`, `github/issue:create`)。
|
|
37
39
|
|
|
38
40
|
---
|
|
39
41
|
|
|
42
|
+
## 📸 界面预览
|
|
43
|
+
|
|
44
|
+
### 全局全景驾驶舱 (Global Cockpit)
|
|
45
|
+
透视账号云端所有 GitHub 仓库、本地工作区矩阵联动状态,以及待办 PR 和 Issues:
|
|
46
|
+
|
|
47
|
+
<p align="center">
|
|
48
|
+
<img src="./docs/images/1.png" alt="GitHub 全景驾驶舱" width="100%" />
|
|
49
|
+
</p>
|
|
50
|
+
|
|
51
|
+
### 会话右侧栏工作区面板 (Session Workspace Panel)
|
|
52
|
+
自动绑定当前工程关联仓库,常驻呈现 PR 状态药丸、Actions 构建流水线与 2x2 黄金操作网格:
|
|
53
|
+
|
|
54
|
+
<p align="center">
|
|
55
|
+
<img src="./docs/images/2.png" alt="右侧栏工作区面板" width="100%" />
|
|
56
|
+
</p>
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
40
60
|
## 🚀 安装指南
|
|
41
61
|
|
|
42
62
|
在 DSH 终端执行以下命令直接从 npm 安装:
|
|
@@ -59,7 +79,7 @@ dsh plugin --profile web add dsh-github-flow
|
|
|
59
79
|
| `github_issue` | `list`, `view`, `create`, `comment`, `close`, `reopen` | 查看与搜索 Issue、创建 Issue、追加排查进展评论 |
|
|
60
80
|
| `github_run` | `list`, `view`, `log_failed`, `rerun`, `cancel` | 查看 CI 构建流水线,特别是 `log_failed` 可直接提取报错日志供 Agent 修复 |
|
|
61
81
|
| `github_repo` | `view`, `search_code`, `search_repos` | 查看仓库元数据、跨仓库代码搜索 |
|
|
62
|
-
| `github_api` | 任意端点(如 `repos/{owner}/{repo}/releases`) | 底层直接调用 REST / GraphQL API(支持 `--jq`
|
|
82
|
+
| `github_api` | 任意端点(如 `repos/{owner}/{repo}/releases`) | 底层直接调用 REST / GraphQL API(支持 `--jq` 预过滤、`raw` 原始响应体模式) |
|
|
63
83
|
|
|
64
84
|
---
|
|
65
85
|
|
|
@@ -83,10 +103,16 @@ dsh plugin --profile web add dsh-github-flow
|
|
|
83
103
|
ghPath: 'gh' # GitHub CLI 程序路径(内网或特定环境可指定绝对路径)
|
|
84
104
|
defaultTimeoutMs: 30000 # 命令执行超时时间(毫秒)
|
|
85
105
|
maxOutputChars: 24000 # 单次命令最大返回字符数(防 Token 溢出保护)
|
|
86
|
-
|
|
106
|
+
maxOutputBytes: 96000 # 单次命令最大返回字节数硬上限,默认 maxOutputChars × 4
|
|
107
|
+
cacheTtlMs: 15000 # 单仓库概览缓存时长(毫秒);全局驾驶舱按此值的 20 倍缓存,下限 5 分钟
|
|
87
108
|
defaultListLimit: 20 # 默认查询列表返回条数
|
|
88
109
|
```
|
|
89
110
|
|
|
111
|
+
> 关于 Web 面板的刷新节奏:全局驾驶舱每次刷新要执行十余次 `gh`(本机实测 8-13 秒),
|
|
112
|
+
> 因此它按 5 分钟缓存,并在你点击「🔄 刷新全局数据」时强制重取。
|
|
113
|
+
> 工作区矩阵只读取本地 `.git/config` 识别仓库归属,不发起网络请求。
|
|
114
|
+
> `/gh status` 命令始终绕过认证缓存,保证你 `gh auth login` 后立刻能看到真实状态。
|
|
115
|
+
|
|
90
116
|
---
|
|
91
117
|
|
|
92
118
|
## 🔌 生态服务扩展 (Cordis Service)
|
package/cordis.patch.yml
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
- insert:
|
|
2
|
-
- id: github-flow
|
|
3
|
-
name:
|
|
2
|
+
- id: github-flow
|
|
3
|
+
name: dsh-github-flow
|
package/docs/architecture.md
CHANGED
|
@@ -55,15 +55,21 @@
|
|
|
55
55
|
- 直接复用宿主机由 `gh auth login` 认证的安全凭证(系统 Keychain / Windows Credential Manager / 系统的加密存储);
|
|
56
56
|
- 天然支持多账号、企业 GitHub (GH Enterprise) 以及各种 OAuth 作用域。
|
|
57
57
|
|
|
58
|
-
### 2.2
|
|
59
|
-
-
|
|
60
|
-
-
|
|
58
|
+
### 2.2 工作区深度亲和与零参数探测 (Workspace Affinity & Auto-Binding)
|
|
59
|
+
- 结合 `$env:DSH_SESSION_ID` 逆向解析 `~/.dsh/storages/workspace.json`,自动锁定当前活跃会话物理工作区;
|
|
60
|
+
- 原生免子进程直接读取工作区 `.git/config`,自动识别远程仓库,免去大模型或人类每次传递 `owner/repo` 的繁重上下文负担;
|
|
61
|
+
- 具备离线优雅兜底机制,无论在何种受限环境下调用均不崩溃。
|
|
61
62
|
|
|
62
|
-
### 2.3 上下文 Token
|
|
63
|
+
### 2.3 上下文 Token 保护与防御性输出包装 (Token Budget Guard)
|
|
63
64
|
- 所有的 `gh` 查询原生采用 `--json <fields>` 结构化投影;
|
|
64
|
-
- 对长文本输出(如大型 PR 的完整 `diff`、CI 失败步骤日志)设置安全截断阈值(默认 24KB
|
|
65
|
+
- 对长文本输出(如大型 PR 的完整 `diff`、CI 失败步骤日志)设置安全截断阈值(默认 24KB),保留头尾关键上下文并添加显式截断标记,杜绝撑爆模型上下文;
|
|
66
|
+
- 所有 Agent 工具的输出统一经过 `normalizeOutput` 归一化,列表自动打包为 `{ items, count }`,标量打包为 `{ result }`,100% 严丝合缝契合 DSH 工具网关模式约束。
|
|
65
67
|
|
|
66
|
-
### 2.4
|
|
67
|
-
-
|
|
68
|
+
### 2.4 原生浅色/深色主题自适应与 2x2 黄金操作栏
|
|
69
|
+
- 全面对齐 DSH 官方 DSW 语义设计变量,基于 `body[data-ds-dark-theme]` 驱动无闪烁双模式切换;
|
|
70
|
+
- 右侧栏 2x2 快捷操作栏彻底废弃失效的 Electron `prompt()` 阻塞弹窗,全面采用官方直达模式(Compare / New Issue),享有 100% 官方富文本体验。
|
|
71
|
+
|
|
72
|
+
### 2.5 Cordis 微内核标准规范
|
|
73
|
+
- **服务依赖就绪**:声明 `['tools', 'commands', 'webServer', 'workspaceRegistry']`,确保 Web 路由与命令服务在环境就绪后激活;
|
|
68
74
|
- **生命周期 Effect 闭环**:所有 HTTP 路由与命令注册均持有 Disposer 清理函数,插件热重载或重新挂载时自动彻底注销,零内存泄漏;
|
|
69
|
-
-
|
|
75
|
+
- **配置自校验与服务暴露**:基于 Standard Schema 规范实现强类型校验与默认值注入,通过 `ctx.provide('github', executor)` 向生态插件暴露单例服务。
|
|
Binary file
|
|
Binary file
|
package/lib/api/routes.d.ts
CHANGED
|
@@ -1,2 +1,7 @@
|
|
|
1
1
|
import type { GhExecutor } from '../executor.js';
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* 由用户配置推导全局驾驶舱的缓存时长。
|
|
4
|
+
* 抽成导出的纯函数:TTL 的推导规则是测试断言的对象,埋在闭包里就只能靠猜。
|
|
5
|
+
*/
|
|
6
|
+
export declare function globalCacheTtlMs(cacheTtlMs: number): number;
|
|
7
|
+
export declare function registerApiRoutes(ctxOrWebServer: any, executor: GhExecutor, workspaceRegistry?: any, cacheTtlMs?: number): void;
|
package/lib/api/routes.js
CHANGED
|
@@ -1,16 +1,89 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { TtlCache } from '../cache.js';
|
|
3
|
+
import { mapLimit, tryReadWorkspaces, findWorkspacePathBySession, findMostRecentWorkspacePath, hasGitDir } from '../workspace.js';
|
|
4
|
+
/** path.basename 在空串/异常输入上会抛错,工作区路径来自磁盘文件,必须防御 */
|
|
5
|
+
function basenameSafe(p) {
|
|
6
|
+
try {
|
|
7
|
+
return path.basename(p) || p;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return p;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** 探测单个工作区时在途的 gh 进程数上限;libuv 线程池默认 4,留出余量给其他请求 */
|
|
14
|
+
const WORKSPACE_PROBE_CONCURRENCY = 3;
|
|
15
|
+
/**
|
|
16
|
+
* 全局驾驶舱的缓存时长下限。
|
|
17
|
+
* 这个端点要 spawn 十几次 gh,比单仓库概览重得多,因此不直接沿用 cacheTtlMs(默认 15s)。
|
|
18
|
+
* 定在 5 分钟的依据:它展示的是「我的仓库 / 待办 PR / Issues」这类分钟级不变的数据,
|
|
19
|
+
* 而每次刷新本机实测要 8-13 秒。缓存太短等于让用户反复等这段延迟——实测 60 秒缓存下,
|
|
20
|
+
* 用户 65 秒后再打开面板仍需等 9.7 秒。仍随 cacheTtlMs 缩放,用户调大时这里同步变长。
|
|
21
|
+
*/
|
|
22
|
+
const GLOBAL_CACHE_MIN_TTL_MS = 300_000;
|
|
23
|
+
/**
|
|
24
|
+
* 由用户配置推导全局驾驶舱的缓存时长。
|
|
25
|
+
* 抽成导出的纯函数:TTL 的推导规则是测试断言的对象,埋在闭包里就只能靠猜。
|
|
26
|
+
*/
|
|
27
|
+
export function globalCacheTtlMs(cacheTtlMs) {
|
|
28
|
+
const base = Number.isFinite(cacheTtlMs) && cacheTtlMs >= 0 ? cacheTtlMs : 15_000;
|
|
29
|
+
return Math.max(base * 20, GLOBAL_CACHE_MIN_TTL_MS);
|
|
30
|
+
}
|
|
31
|
+
/** open_browser 只允许 http/https,杜绝 file:// 与自定义协议被当作打开目标 */
|
|
32
|
+
function isSafeExternalUrl(raw) {
|
|
33
|
+
if (typeof raw !== 'string' || raw.length === 0 || raw.length > 2048)
|
|
34
|
+
return false;
|
|
35
|
+
try {
|
|
36
|
+
const parsed = new URL(raw);
|
|
37
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function registerApiRoutes(ctxOrWebServer, executor, workspaceRegistry, cacheTtlMs = 15_000) {
|
|
44
|
+
const ctx = ctxOrWebServer;
|
|
45
|
+
const webServer = ctx?.webServer || ctx;
|
|
46
|
+
if (!webServer || typeof webServer.register !== 'function')
|
|
5
47
|
return;
|
|
48
|
+
const cacheMap = new TtlCache(cacheTtlMs);
|
|
49
|
+
const globalCache = new TtlCache(globalCacheTtlMs(cacheTtlMs));
|
|
6
50
|
function sendJson(res, status, data) {
|
|
7
51
|
res.statusCode = status;
|
|
8
52
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
9
53
|
res.setHeader('Cache-Control', 'no-store');
|
|
10
54
|
res.end(JSON.stringify(data));
|
|
11
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* 注册单个路由并返回注销器。
|
|
58
|
+
* 为什么需要前置探测与清理:Cordis 插件在经历热重载、异常崩溃或重新启用时,若先前的 disposer
|
|
59
|
+
* 未能顺利执行,宿主全局长生命周期的 WebServer 内部 exact/prefixes 映射表中会残留历史路由;
|
|
60
|
+
* 而 WebServer.register 会在键冲突时直接抛出 duplicate route 异常阻断激活。
|
|
61
|
+
* 前置探测并清理残留条目可实现自愈防护与安全幂等注册。
|
|
62
|
+
*/
|
|
63
|
+
const registerOneRoute = (route) => {
|
|
64
|
+
const table = route.kind === 'exact' ? webServer.exact : webServer.prefixes;
|
|
65
|
+
if (table && typeof table.delete === 'function' && table.has(route.path)) {
|
|
66
|
+
table.delete(route.path);
|
|
67
|
+
}
|
|
68
|
+
return webServer.register(route);
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* 将路由生命周期绑定到 Cordis 上下文作用域。
|
|
72
|
+
* 为什么必须包裹在 ctx.effect 中:Cordis 框架要求外部服务的绑定通过 effect 纳管,
|
|
73
|
+
* 当插件被禁用、配置更新热重载或 scope 销毁时,Cordis 会自动执行返回的 disposer,
|
|
74
|
+
* 干净地从 WebServer 中注销路由,彻底杜绝孤儿路由残留与重复注册报错。
|
|
75
|
+
* 当在缺乏 ctx.effect 的简易单元测试环境下运行时,优雅退化为直接注册。
|
|
76
|
+
*/
|
|
77
|
+
const bindRoute = (route) => {
|
|
78
|
+
if (ctx && typeof ctx.effect === 'function') {
|
|
79
|
+
ctx.effect(() => registerOneRoute(route), `github-flow: ${route.kind} ${route.path}`);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
registerOneRoute(route);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
12
85
|
// 1. 获取全局与当前仓库的 GitHub 概览
|
|
13
|
-
|
|
86
|
+
bindRoute({
|
|
14
87
|
kind: 'exact',
|
|
15
88
|
path: '/api/github/overview',
|
|
16
89
|
handler: async (req, res) => {
|
|
@@ -22,58 +95,20 @@ export function registerApiRoutes(webServerService, executor, workspaceRegistry)
|
|
|
22
95
|
const url = new URL(req.url || '', 'http://127.0.0.1');
|
|
23
96
|
let cwd = url.searchParams.get('cwd');
|
|
24
97
|
const sessionId = url.searchParams.get('sessionId');
|
|
25
|
-
//
|
|
98
|
+
// 会话 ID → 物理路径,以及「最近活跃工作区」兜底,统一由 workspace 模块提供
|
|
26
99
|
if (!cwd && sessionId) {
|
|
27
|
-
|
|
28
|
-
const fs = await import('node:fs');
|
|
29
|
-
const path = await import('node:path');
|
|
30
|
-
const os = await import('node:os');
|
|
31
|
-
const wsJsonPath = path.join(os.homedir(), '.dsh', 'storages', 'workspace.json');
|
|
32
|
-
if (fs.existsSync(wsJsonPath)) {
|
|
33
|
-
const wsData = JSON.parse(fs.readFileSync(wsJsonPath, 'utf8'));
|
|
34
|
-
const workspaces = wsData.tables?.workspaces || {};
|
|
35
|
-
for (const wsId of Object.keys(workspaces)) {
|
|
36
|
-
const item = workspaces[wsId];
|
|
37
|
-
if (item.sessionIds && item.sessionIds.includes(sessionId)) {
|
|
38
|
-
cwd = item.path;
|
|
39
|
-
break;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
catch (e) { }
|
|
100
|
+
cwd = findWorkspacePathBySession(sessionId) || null;
|
|
45
101
|
}
|
|
46
|
-
// 2. 若仍未获取,从 workspace.json 自动匹配最近活跃的工作区
|
|
47
102
|
if (!cwd) {
|
|
48
|
-
|
|
49
|
-
const fs = await import('node:fs');
|
|
50
|
-
const path = await import('node:path');
|
|
51
|
-
const os = await import('node:os');
|
|
52
|
-
const wsJsonPath = path.join(os.homedir(), '.dsh', 'storages', 'workspace.json');
|
|
53
|
-
if (fs.existsSync(wsJsonPath)) {
|
|
54
|
-
const wsData = JSON.parse(fs.readFileSync(wsJsonPath, 'utf8'));
|
|
55
|
-
const workspaces = wsData.tables?.workspaces || {};
|
|
56
|
-
let latestItem = null;
|
|
57
|
-
for (const wsId of Object.keys(workspaces)) {
|
|
58
|
-
const item = workspaces[wsId];
|
|
59
|
-
if (!latestItem || new Date(item.updatedAt || 0) > new Date(latestItem.updatedAt || 0)) {
|
|
60
|
-
latestItem = item;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
if (latestItem && latestItem.path) {
|
|
64
|
-
cwd = latestItem.path;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
catch (e) { }
|
|
103
|
+
cwd = findMostRecentWorkspacePath() || null;
|
|
69
104
|
}
|
|
70
105
|
if (!cwd) {
|
|
71
106
|
cwd = process.cwd();
|
|
72
107
|
}
|
|
73
108
|
const now = Date.now();
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
76
|
-
sendJson(res, 200, { ok: true, data:
|
|
109
|
+
const cachedData = cacheMap.get(cwd, now);
|
|
110
|
+
if (cachedData) {
|
|
111
|
+
sendJson(res, 200, { ok: true, data: cachedData, fromCache: true, cwd });
|
|
77
112
|
return;
|
|
78
113
|
}
|
|
79
114
|
const auth = await executor.checkAuth(cwd);
|
|
@@ -84,9 +119,9 @@ export function registerApiRoutes(webServerService, executor, workspaceRegistry)
|
|
|
84
119
|
if (repo && auth.loggedIn) {
|
|
85
120
|
// 并发拉取当前文件夹仓库的 PRs、Issues 和 Runs
|
|
86
121
|
const [prsRes, issuesRes, runsRes] = await Promise.all([
|
|
87
|
-
executor.run(['pr', 'list', '--json', 'number,title,state,author,headRefName,baseRefName,isDraft,mergeable,reviewDecision,statusCheckRollup,url,updatedAt', '-L', '10'], { cwd, timeoutMs:
|
|
88
|
-
executor.run(['issue', 'list', '--json', 'number,title,state,author,labels,assignees,url,updatedAt', '-L', '10'], { cwd, timeoutMs:
|
|
89
|
-
executor.run(['run', 'list', '--json', 'databaseId,name,status,conclusion,event,headBranch,url,createdAt', '-L', '5'], { cwd, timeoutMs:
|
|
122
|
+
executor.run(['pr', 'list', '--json', 'number,title,state,author,headRefName,baseRefName,isDraft,mergeable,reviewDecision,statusCheckRollup,url,updatedAt', '-L', '10'], { cwd, timeoutMs: 8_000 }),
|
|
123
|
+
executor.run(['issue', 'list', '--json', 'number,title,state,author,labels,assignees,url,updatedAt', '-L', '10'], { cwd, timeoutMs: 8_000 }),
|
|
124
|
+
executor.run(['run', 'list', '--json', 'databaseId,name,status,conclusion,event,headBranch,url,createdAt', '-L', '5'], { cwd, timeoutMs: 8_000 }),
|
|
90
125
|
]);
|
|
91
126
|
if (prsRes.ok && Array.isArray(prsRes.data))
|
|
92
127
|
pullRequests = prsRes.data;
|
|
@@ -103,7 +138,7 @@ export function registerApiRoutes(webServerService, executor, workspaceRegistry)
|
|
|
103
138
|
runs,
|
|
104
139
|
lastUpdated: new Date().toISOString(),
|
|
105
140
|
};
|
|
106
|
-
cacheMap.set(cwd,
|
|
141
|
+
cacheMap.set(cwd, freshData, now);
|
|
107
142
|
sendJson(res, 200, { ok: true, data: freshData, fromCache: false, cwd });
|
|
108
143
|
}
|
|
109
144
|
catch (err) {
|
|
@@ -112,16 +147,17 @@ export function registerApiRoutes(webServerService, executor, workspaceRegistry)
|
|
|
112
147
|
},
|
|
113
148
|
});
|
|
114
149
|
// 2. 强制刷新缓存
|
|
115
|
-
|
|
150
|
+
bindRoute({
|
|
116
151
|
kind: 'exact',
|
|
117
152
|
path: '/api/github/refresh',
|
|
118
153
|
handler: async (req, res) => {
|
|
119
154
|
cacheMap.clear();
|
|
155
|
+
globalCache.clear();
|
|
120
156
|
sendJson(res, 200, { ok: true, message: '缓存已清空,下次读取将拉取最新数据' });
|
|
121
157
|
},
|
|
122
158
|
});
|
|
123
159
|
// 3. 右侧栏快捷操作代理接口
|
|
124
|
-
|
|
160
|
+
bindRoute({
|
|
125
161
|
kind: 'exact',
|
|
126
162
|
path: '/api/github/action',
|
|
127
163
|
handler: async (req, res) => {
|
|
@@ -155,11 +191,24 @@ export function registerApiRoutes(webServerService, executor, workspaceRegistry)
|
|
|
155
191
|
break;
|
|
156
192
|
}
|
|
157
193
|
case 'open_browser': {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
194
|
+
// 原实现把 payload.url 直接拼进 shell 字符串再交给 exec(),
|
|
195
|
+
// 形如 https://x" & calc & " 的 URL 可闭合引号并追加任意命令——本机任意页面
|
|
196
|
+
// 都能通过 POST 这个端点触发命令执行。
|
|
197
|
+
// 现在改为:先用 URL 解析白名单协议,再用 execFile 参数数组传递,全程不经过 shell。
|
|
198
|
+
if (!isSafeExternalUrl(payload.url)) {
|
|
199
|
+
throw new Error('url 参数无效:只允许 http/https 开头的完整地址');
|
|
200
|
+
}
|
|
201
|
+
const { execFile } = await import('node:child_process');
|
|
202
|
+
const [bin, args] = process.platform === 'win32'
|
|
203
|
+
? ['cmd', ['/c', 'start', '', payload.url]]
|
|
204
|
+
: process.platform === 'darwin'
|
|
205
|
+
? ['open', [payload.url]]
|
|
206
|
+
: ['xdg-open', [payload.url]];
|
|
207
|
+
// 不 await,也不让打开失败反向影响接口语义;仅记录以便排查
|
|
208
|
+
execFile(bin, args, (err) => {
|
|
209
|
+
if (err)
|
|
210
|
+
console.warn('[dsh-github-flow] 打开浏览器失败:', err?.message || err);
|
|
211
|
+
});
|
|
163
212
|
sendJson(res, 200, { ok: true, message: '已在浏览器打开' });
|
|
164
213
|
break;
|
|
165
214
|
}
|
|
@@ -174,7 +223,7 @@ export function registerApiRoutes(webServerService, executor, workspaceRegistry)
|
|
|
174
223
|
},
|
|
175
224
|
});
|
|
176
225
|
// 4. 获取全局视角的驾驶舱概览数据 (不绑定单一项目)
|
|
177
|
-
|
|
226
|
+
bindRoute({
|
|
178
227
|
kind: 'exact',
|
|
179
228
|
path: '/api/github/global-overview',
|
|
180
229
|
handler: async (req, res) => {
|
|
@@ -182,18 +231,29 @@ export function registerApiRoutes(webServerService, executor, workspaceRegistry)
|
|
|
182
231
|
sendJson(res, 405, { ok: false, message: '仅支持 GET 请求' });
|
|
183
232
|
return;
|
|
184
233
|
}
|
|
234
|
+
// 全局驾驶舱要 spawn 十几次 gh(认证 + 仓库 + PR + Issue + 每个工作区探测),
|
|
235
|
+
// 实测冷启动曾达 18.9 秒。缓存是这里唯一的解药:面板每次挂载都会来取一次。
|
|
236
|
+
const now = Date.now();
|
|
237
|
+
const cachedGlobal = globalCache.get('global', now);
|
|
238
|
+
if (cachedGlobal) {
|
|
239
|
+
sendJson(res, 200, { ok: true, data: cachedGlobal, fromCache: true });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
185
242
|
try {
|
|
186
243
|
const auth = await executor.checkAuth();
|
|
187
244
|
let userRepos = [];
|
|
188
245
|
let myPrs = [];
|
|
189
246
|
let myIssues = [];
|
|
190
247
|
let workspaceMatrix = [];
|
|
248
|
+
let workspaceError;
|
|
191
249
|
if (auth.loggedIn) {
|
|
192
250
|
// 并发执行:1. 账号下仓库列表 2. 个人 PRs 3. 个人 Issues
|
|
251
|
+
// 超时压到 8 秒:本机实测 `gh search issues --author=@me` 单次就要 10 秒,
|
|
252
|
+
// 它是这组的木桶短板,直接决定首屏等待时间。面板查询不值得为它多等 4 秒。
|
|
193
253
|
const [reposRes, prsRes, issuesRes] = await Promise.all([
|
|
194
|
-
executor.run(['repo', 'list', '--limit', '20', '--json', 'name,nameWithOwner,description,defaultBranchRef,isPrivate,stargazerCount,updatedAt,url'], { timeoutMs:
|
|
195
|
-
executor.run(['search', 'prs', '--author=@me', '--state=open', '--limit', '10', '--json', 'number,title,repository,url,updatedAt'], { timeoutMs:
|
|
196
|
-
executor.run(['search', 'issues', '--author=@me', '--state=open', '--limit', '10', '--json', 'number,title,repository,url,updatedAt'], { timeoutMs:
|
|
254
|
+
executor.run(['repo', 'list', '--limit', '20', '--json', 'name,nameWithOwner,description,defaultBranchRef,isPrivate,stargazerCount,updatedAt,url'], { timeoutMs: 8_000 }),
|
|
255
|
+
executor.run(['search', 'prs', '--author=@me', '--state=open', '--limit', '10', '--json', 'number,title,repository,url,updatedAt'], { timeoutMs: 8_000 }),
|
|
256
|
+
executor.run(['search', 'issues', '--author=@me', '--state=open', '--limit', '10', '--json', 'number,title,repository,url,updatedAt'], { timeoutMs: 8_000 }),
|
|
197
257
|
]);
|
|
198
258
|
if (reposRes.ok && Array.isArray(reposRes.data)) {
|
|
199
259
|
userRepos = reposRes.data.map((r) => ({
|
|
@@ -214,36 +274,27 @@ export function registerApiRoutes(webServerService, executor, workspaceRegistry)
|
|
|
214
274
|
myIssues = issuesRes.data;
|
|
215
275
|
}
|
|
216
276
|
}
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
path: ws.path,
|
|
238
|
-
hasGit,
|
|
239
|
-
repo: repoMeta,
|
|
240
|
-
sessionCount: (ws.sessionIds && ws.sessionIds.length) || 0,
|
|
241
|
-
};
|
|
242
|
-
}));
|
|
243
|
-
workspaceMatrix = matrixResults;
|
|
244
|
-
}
|
|
277
|
+
// 解析本地所有工作区与 GitHub 关联矩阵
|
|
278
|
+
const wsRead = tryReadWorkspaces();
|
|
279
|
+
workspaceError = wsRead.error;
|
|
280
|
+
if (!workspaceError) {
|
|
281
|
+
const wsEntries = wsRead.workspaces.filter((ws) => ws.path);
|
|
282
|
+
// 有界并发探测:每个工作区最多 spawn 2 个 gh 进程,无上限并发会让它们全挤进
|
|
283
|
+
// libuv 默认 4 线程的队列里排队,延迟随工作区数量线性劣化。
|
|
284
|
+
// 这里刻意只用本地 .git/config 识别身份、不发网络请求——全局矩阵只需要「属于哪个仓库」,
|
|
285
|
+
// 每次网络往返都要秒级,5 个工作区就足以把该端点拖到 20 秒。
|
|
286
|
+
workspaceMatrix = await mapLimit(wsEntries, WORKSPACE_PROBE_CONCURRENCY, async (ws) => {
|
|
287
|
+
const hasGit = hasGitDir(ws.path);
|
|
288
|
+
return {
|
|
289
|
+
id: ws.id,
|
|
290
|
+
title: ws.title || basenameSafe(ws.path),
|
|
291
|
+
path: ws.path,
|
|
292
|
+
hasGit,
|
|
293
|
+
repo: hasGit ? executor.getRepoIdentityFromLocal(ws.path) || undefined : undefined,
|
|
294
|
+
sessionCount: ws.sessionIds.length,
|
|
295
|
+
};
|
|
296
|
+
});
|
|
245
297
|
}
|
|
246
|
-
catch (e) { }
|
|
247
298
|
const globalData = {
|
|
248
299
|
auth,
|
|
249
300
|
userRepos,
|
|
@@ -251,8 +302,10 @@ export function registerApiRoutes(webServerService, executor, workspaceRegistry)
|
|
|
251
302
|
myPrs,
|
|
252
303
|
myIssues,
|
|
253
304
|
lastUpdated: new Date().toISOString(),
|
|
305
|
+
...(workspaceError ? { workspaceError } : {}),
|
|
254
306
|
};
|
|
255
|
-
|
|
307
|
+
globalCache.set('global', globalData, now);
|
|
308
|
+
sendJson(res, 200, { ok: true, data: globalData, fromCache: false });
|
|
256
309
|
}
|
|
257
310
|
catch (err) {
|
|
258
311
|
sendJson(res, 500, { ok: false, error: err.message || '获取全局驾驶舱数据失败' });
|
package/lib/cache.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 有上限的 TTL 缓存。
|
|
3
|
+
*
|
|
4
|
+
* 为什么独立成模块:缓存语义(过期、LRU 腾退、容量上限)是缺陷高发区,但嵌在路由文件里
|
|
5
|
+
* 就无法单测——import 一个已执行过的路由模块拿不到干净实例。独立出来才能覆盖边界。
|
|
6
|
+
*/
|
|
7
|
+
export declare class TtlCache<T> {
|
|
8
|
+
private ttlMs;
|
|
9
|
+
private maxEntries;
|
|
10
|
+
private store;
|
|
11
|
+
/**
|
|
12
|
+
* @param ttlMs 命中有效期;由面向用户的 cacheTtlMs 配置驱动
|
|
13
|
+
* @param maxEntries 容量上限。键通常是工作区路径,一次会话就可能引入若干键,
|
|
14
|
+
* 没有上限时长期运行的 Web 服务会慢性增长(旧实现只有 /refresh 会清空)
|
|
15
|
+
*/
|
|
16
|
+
constructor(ttlMs: number, maxEntries?: number);
|
|
17
|
+
get(key: string, now: number): T | undefined;
|
|
18
|
+
set(key: string, data: T, now: number): void;
|
|
19
|
+
get size(): number;
|
|
20
|
+
clear(): void;
|
|
21
|
+
}
|
package/lib/cache.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 有上限的 TTL 缓存。
|
|
3
|
+
*
|
|
4
|
+
* 为什么独立成模块:缓存语义(过期、LRU 腾退、容量上限)是缺陷高发区,但嵌在路由文件里
|
|
5
|
+
* 就无法单测——import 一个已执行过的路由模块拿不到干净实例。独立出来才能覆盖边界。
|
|
6
|
+
*/
|
|
7
|
+
export class TtlCache {
|
|
8
|
+
ttlMs;
|
|
9
|
+
maxEntries;
|
|
10
|
+
store = new Map();
|
|
11
|
+
/**
|
|
12
|
+
* @param ttlMs 命中有效期;由面向用户的 cacheTtlMs 配置驱动
|
|
13
|
+
* @param maxEntries 容量上限。键通常是工作区路径,一次会话就可能引入若干键,
|
|
14
|
+
* 没有上限时长期运行的 Web 服务会慢性增长(旧实现只有 /refresh 会清空)
|
|
15
|
+
*/
|
|
16
|
+
constructor(ttlMs, maxEntries = 64) {
|
|
17
|
+
this.ttlMs = ttlMs;
|
|
18
|
+
this.maxEntries = maxEntries;
|
|
19
|
+
}
|
|
20
|
+
get(key, now) {
|
|
21
|
+
const hit = this.store.get(key);
|
|
22
|
+
if (!hit)
|
|
23
|
+
return undefined;
|
|
24
|
+
if (now - hit.time >= this.ttlMs) {
|
|
25
|
+
this.store.delete(key);
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
// 命中后重新插入:Map 的迭代顺序即插入顺序,重插让顺序逼近 LRU,腾退时淘汰最久未用者
|
|
29
|
+
this.store.delete(key);
|
|
30
|
+
this.store.set(key, hit);
|
|
31
|
+
return hit.data;
|
|
32
|
+
}
|
|
33
|
+
set(key, data, now) {
|
|
34
|
+
this.store.set(key, { data, time: now });
|
|
35
|
+
while (this.store.size > this.maxEntries) {
|
|
36
|
+
const oldest = this.store.keys().next();
|
|
37
|
+
if (oldest.done)
|
|
38
|
+
break;
|
|
39
|
+
this.store.delete(oldest.value);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
get size() {
|
|
43
|
+
return this.store.size;
|
|
44
|
+
}
|
|
45
|
+
clear() {
|
|
46
|
+
this.store.clear();
|
|
47
|
+
}
|
|
48
|
+
}
|