dsh-cnboard 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xqitw
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # DSH-CNBoard
2
+
3
+ [![npm version](https://img.shields.io/npm/v/dsh-cnboard.svg?color=cb3837&logo=npm)](https://www.npmjs.com/package/dsh-cnboard)
4
+ [![License](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
5
+ [![Node](https://img.shields.io/badge/Node-20%2B-339933.svg?logo=node.js)](https://nodejs.org)
6
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5%2B-3178C6.svg?logo=typescript)](https://www.typescriptlang.org)
7
+
8
+ **项目地址**:<https://cnb.cool/xqitw/dsh-cnboard>
9
+
10
+ dsh(deepseek-harness)Web 的 CNB 平台管理插件:以 **CNB 任务集**为数据源,跨组织、跨仓库聚合 Issue/PR 任务看板。配置仅需两项——**CNB token + 任务集路径**。
11
+
12
+ 设计文档(含模块扩展规划:流水线管理 M2、云环境管理 M3、NPC 联动 M4)见仓库 issue:
13
+ [#2 总体设计](https://cnb.cool/xqitw/dsh-cnboard/-/issues/2) ·
14
+ [#3 页面布局设计](https://cnb.cool/xqitw/dsh-cnboard/-/issues/3) ·
15
+ [#4 API 接入基线](https://cnb.cool/xqitw/dsh-cnboard/-/issues/4)
16
+
17
+ ## 功能(M1 看板 MVP)
18
+
19
+ - **跨仓库聚合看板**:任务集数据范围内的全部 Issue/PR 混排一板,状态(待处理/进行中/待评审/已完成)、优先级、仓库三种分组自由切换
20
+ - **卡片信息**:类型着色(蓝=Issue / 紫=PR)、优先级章(P0 红 / P1 橙)、标签、处理人/评审人头像、评论数、相对更新时间;PR 卡含「可合并」标记
21
+ - **过滤与搜索**:类型过滤(全部/Issue/PR)+ 标题/仓库关键字
22
+ - **详情抽屉**:字段总览 + 操作位(评论/关闭/合并/召唤 NPC,M1.5 开放)+「在 CNB 打开」直达
23
+ - **配置与体检**:token + 任务集两项设置;保存即做 **scope 体检**(任务集/Issue/PR/流水线/云环境五类逐项探测),缺 scope 时明确提示缺哪几项、去哪补
24
+ - **主题兼容**:颜色全部使用宿主 `--dsw-*` 设计令牌,亮/暗/第三方主题自适应;UI 挂载于官方 `settings.section` 扩展点
25
+
26
+ ## 安装
27
+
28
+ 作为 dsh profile bundle 使用,**推荐用 dsh 官方命令**:
29
+
30
+ ```bash
31
+ dsh plugin --profile web add dsh-cnboard
32
+ ```
33
+
34
+ 该命令会在 profile 目录执行 `pnpm add`,检测到包的 `dsh.bundle` 声明后自动加入 `dsh.profile.bundles`,重启 dsh 后设置页出现「CNBoard」页签。
35
+
36
+ ## 配置
37
+
38
+ 进入 dsh Web **设置 → CNBoard**:
39
+
40
+ 1. **token**:CNB 访问令牌([创建入口](https://cnb.cool/profile/token))。授权范围建议勾选 `mission-manage` 及 issues/pulls 相关读写;保存后插件自动做 scope 体检并提示缺失项
41
+ 2. **任务集**:看板数据源,形如 `xqitw/missions`。数据范围(关联哪些仓库)在 CNB 任务集设置中维护,插件不重复建设
42
+
43
+ 配置落盘 `~/.dsh/cnboard.json`;也可经 profile 的 `cordis.patch.yml` 行配置或环境变量 `CNB_TOKEN` 提供(优先级:设置页 > profile 配置 > 环境变量)。
44
+
45
+ ## 架构
46
+
47
+ ```text
48
+ 浏览器(settings.section 页签,lib/client.js 手写 __ModuleLoader__ 模块)
49
+ └─ fetch /cnboard-api/*(同源)
50
+ dsh Host(src/index.ts,tsc 构建 lib/index.js)
51
+ ├─ /cnboard-api/state 任务集 resources 聚合(游标翻页全量拉取 + 规范化)
52
+ ├─ /cnboard-api/config 配置读写(token 不回传原文)
53
+ ├─ /cnboard-api/healthcheck scope 体检
54
+ └─ CNB 接入:官方 SDK node-cnb(错误统一转换为 {status, errcode, errmsg})
55
+ ```
56
+
57
+ 设计约定:
58
+
59
+ - token 只存 Host 半区,浏览器不接触
60
+ - 聚合读走任务集接口(平台代做跨仓库查询),单仓库写操作(M1.5)回落 issues/pulls 端点
61
+ - POST 仅接受白名单方法、有界 JSON body;安全立场与 dsh-skills-manager 一致(宿主 webserver 既有信任边界)
62
+
63
+ ## 开发
64
+
65
+ ```bash
66
+ npm install --legacy-peer-deps
67
+ npm run build # tsc,产出 lib/index.js(lib/client.js 为手写源码,直接维护)
68
+ npm run lint:md
69
+ npm pack --dry-run
70
+ ```
71
+
72
+ 本地调试:`dsh plugin --profile web add link:/path/to/dsh-cnboard` 后重启 dsh。
73
+
74
+ ## 授权范围(scope)速查
75
+
76
+ | 能力 | 需要的 scope |
77
+ |---|---|
78
+ | 看板(任务集聚合查询) | `mission-manage:r` |
79
+ | Issue/PR 读写操作(M1.5) | issues / pulls 对应读写 |
80
+ | 流水线管理(M2) | build 对应读写 |
81
+ | 云环境管理(M3) | workspace 对应读写 |
82
+
83
+ 以插件内体检结果与 CNB 报错信息为准(官方文档未列出 scope 全集)。
84
+
85
+ ## License
86
+
87
+ [MIT](./LICENSE)
@@ -0,0 +1,12 @@
1
+ # dsh-cnboard — profile bundle patch
2
+ # 作为 dsh profile 的一个 bundle 层插入。host 端注册 /cnboard-api/* 路由,
3
+ # client 端注册 settings.section 的 CNBoard 页签。
4
+ - insert:
5
+ - id: cnboard
6
+ name: 'dsh-cnboard'
7
+ config:
8
+ # CNB 访问令牌(建议留空,在 DSH 设置页 → CNBoard 中配置,落盘 ~/.dsh/cnboard.json)
9
+ # 也可在此直接写入 token,或依赖环境变量 CNB_TOKEN
10
+ cnbToken: ''
11
+ # 任务集路径(org/mission),看板数据源
12
+ mission: 'xqitw/missions'
package/lib/client.js ADDED
@@ -0,0 +1,545 @@
1
+ /**
2
+ * dsh-cnboard — client bundle(标准 __ModuleLoader__ 模块形态)
3
+ *
4
+ * 手写等效产物:window.__ModuleLoader__.load({ id, factory }),factory 内
5
+ * require 依赖并注入 CSS(data-plugin-css 防重复)。不依赖 rollup 构建链。
6
+ * 与 dsh-skills-manager 的 lib/client.js 同构。
7
+ *
8
+ * - host.call('x') → fetch("/cnboard-api/x"),POST JSON
9
+ * - 颜色全部使用宿主 --dsw-* 设计令牌,亮/暗/第三方主题自适应
10
+ *
11
+ * 注意:react/jsx-runtime 的 jsx(type, props, key?) 第三参是 key,
12
+ * children 必须放在 props.children 里(本文件全部按此正确写法)。
13
+ */
14
+ window.__ModuleLoader__.load({
15
+ id: "dsh-cnboard",
16
+ factory: (require) => {
17
+ var module = { exports: {} }
18
+ var exports = module.exports
19
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" })
20
+
21
+ var react = require("react")
22
+ var reactJsxRuntime = require("react/jsx-runtime")
23
+
24
+ // ── CSS(全部使用宿主 --dsw-* 设计令牌)──
25
+ var CSS = [
26
+ ".cnb-section { display: flex; flex-direction: column; gap: 12px; padding: 4px 2px 24px; }",
27
+ ".cnb-card { background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; }",
28
+ ".cnb-config { padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; }",
29
+ ".cnb-config-title { font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary); }",
30
+ ".cnb-config-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }",
31
+ ".cnb-config-row label { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--dsw-alias-label-secondary); min-width: 0; }",
32
+ ".cnb-config-row label > span { flex: none; }",
33
+ ".cnb-input { background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; color: var(--dsw-alias-label-primary); padding: 6px 10px; font-size: 13px; outline: none; min-width: 0; }",
34
+ ".cnb-input:focus { border-color: var(--dsw-alias-brand-primary); }",
35
+ ".cnb-input-token { width: 260px; }",
36
+ ".cnb-input-mission { width: 200px; }",
37
+ ".cnb-config-hint { font-size: 12px; color: var(--dsw-alias-label-tertiary); line-height: 1.6; }",
38
+ ".cnb-btn { border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); border-radius: 8px; padding: 5px 14px; font-size: 13px; cursor: pointer; }",
39
+ ".cnb-btn:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }",
40
+ ".cnb-btn:disabled { opacity: .5; cursor: not-allowed; }",
41
+ ".cnb-btn-primary { background: var(--dsw-alias-button-primary-fill); border-color: transparent; color: var(--dsw-alias-label-primary-foreground); font-weight: 600; }",
42
+ ".cnb-btn-primary:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); }",
43
+ ".cnb-scope-row { display: flex; gap: 6px; flex-wrap: wrap; }",
44
+ ".cnb-scope { font-size: 11.5px; border-radius: 999px; padding: 2px 10px; border: 1px solid var(--dsw-alias-border-l2); color: var(--dsw-alias-label-secondary); }",
45
+ ".cnb-scope-ok { color: var(--dsw-alias-state-success-primary); border-color: var(--dsw-alias-state-success-primary); }",
46
+ ".cnb-scope-bad { color: var(--dsw-alias-state-error-primary); border-color: var(--dsw-alias-state-error-primary); }",
47
+ ".cnb-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }",
48
+ ".cnb-search { flex: 0 1 220px; }",
49
+ ".cnb-seg { display: inline-flex; background: var(--dsw-alias-bg-layer-2); border-radius: 8px; padding: 2px; gap: 2px; }",
50
+ ".cnb-seg button { border: 0; background: transparent; color: var(--dsw-alias-label-secondary); border-radius: 6px; padding: 4px 12px; font-size: 13px; cursor: pointer; }",
51
+ ".cnb-seg button[aria-pressed='true'] { background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.14); }",
52
+ ".cnb-select { background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; color: var(--dsw-alias-label-primary); padding: 5px 8px; font-size: 13px; }",
53
+ ".cnb-stats { display: flex; gap: 8px; flex-wrap: wrap; }",
54
+ ".cnb-stat { display: inline-flex; align-items: center; gap: 6px; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 3px 10px; font-size: 12.5px; color: var(--dsw-alias-label-secondary); }",
55
+ ".cnb-stat b { color: var(--dsw-alias-label-primary); }",
56
+ ".cnb-sw { width: 9px; height: 9px; border-radius: 3px; display: inline-block; flex: none; }",
57
+ ".cnb-board { display: grid; grid-auto-flow: column; grid-auto-columns: 260px; gap: 10px; overflow-x: auto; align-items: start; padding-bottom: 12px; }",
58
+ ".cnb-col { background: var(--dsw-alias-bg-layer-2); border-radius: 10px; padding: 8px; min-height: 90px; }",
59
+ ".cnb-col-head { display: flex; align-items: center; gap: 7px; padding: 2px 4px 8px; }",
60
+ ".cnb-col-head b { font-size: 12.5px; color: var(--dsw-alias-label-primary); }",
61
+ ".cnb-col-count { font-size: 11px; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-layer-1); border-radius: 999px; padding: 0 7px; line-height: 16px; }",
62
+ ".cnb-col-cards { display: flex; flex-direction: column; gap: 7px; }",
63
+ ".cnb-empty { color: var(--dsw-alias-label-tertiary); font-size: 12px; text-align: center; padding: 14px 0; }",
64
+ ".cnb-item { display: block; width: 100%; text-align: left; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); border-left-width: 3px; border-radius: 8px; padding: 9px 11px; cursor: pointer; font: inherit; color: inherit; }",
65
+ ".cnb-item:hover { border-color: var(--dsw-alias-brand-primary); border-left-color: inherit; }",
66
+ ".cnb-item.t-issue { border-left-color: var(--dsw-alias-brand-primary); }",
67
+ ".cnb-item.t-pr { border-left-color: var(--dsw-alias-state-business-primary); }",
68
+ ".cnb-item-title { margin: 0 0 6px; font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }",
69
+ ".cnb-meta { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; margin-bottom: 5px; }",
70
+ ".cnb-ref { font-size: 11px; color: var(--dsw-alias-label-secondary); font-family: ui-monospace, 'SF Mono', Consolas, monospace; }",
71
+ ".cnb-ref a { color: inherit; text-decoration: none; }",
72
+ ".cnb-ref a:hover { color: var(--dsw-alias-brand-primary); text-decoration: underline; }",
73
+ ".cnb-chip { font-size: 10.5px; font-weight: 700; border-radius: 4px; padding: 1px 5px; line-height: 14px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary); }",
74
+ ".cnb-chip-issue { background: var(--dsw-alias-brand-primary); color: var(--dsw-alias-label-primary-foreground); }",
75
+ ".cnb-chip-pr { background: var(--dsw-alias-state-business-primary); color: var(--dsw-alias-label-primary-foreground); }",
76
+ ".cnb-chip-p0 { background: var(--dsw-alias-state-error-primary); color: var(--dsw-alias-label-primary-foreground); }",
77
+ ".cnb-chip-p1 { background: var(--dsw-alias-state-warn-primary); color: var(--dsw-alias-label-primary-foreground); }",
78
+ ".cnb-chip-ok { color: var(--dsw-alias-state-success-primary); }",
79
+ ".cnb-chip-warn { color: var(--dsw-alias-state-warn-primary); }",
80
+ ".cnb-avatars { display: flex; }",
81
+ ".cnb-avatar { width: 19px; height: 19px; border-radius: 50%; font-size: 9.5px; font-weight: 700; display: inline-grid; place-items: center; border: 2px solid var(--dsw-alias-bg-layer-1); margin-left: -6px; }",
82
+ ".cnb-avatar:first-child { margin-left: 0; }",
83
+ ".cnb-avatar-as { background: var(--dsw-alias-brand-primary); color: var(--dsw-alias-label-primary-foreground); }",
84
+ ".cnb-avatar-rv { background: var(--dsw-alias-state-business-primary); color: var(--dsw-alias-label-primary-foreground); }",
85
+ ".cnb-foot { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--dsw-alias-label-tertiary); }",
86
+ ".cnb-foot .grow { flex: 1; }",
87
+ ".cnb-drawer-scrim { position: fixed; inset: 0; background: var(--dsw-alias-bg-mask-1); z-index: 60; }",
88
+ ".cnb-drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 61; width: min(430px, 92vw); background: var(--dsw-alias-bg-layer-1); border-left: 1px solid var(--dsw-alias-border-l1); box-shadow: var(--dsw-shadow-lv3); display: flex; flex-direction: column; }",
89
+ ".cnb-drawer-head { padding: 14px 18px 10px; border-bottom: 1px solid var(--dsw-alias-border-l1); }",
90
+ ".cnb-drawer-head h3 { margin: 4px 0 0; font-size: 15px; line-height: 1.45; color: var(--dsw-alias-label-primary); }",
91
+ ".cnb-drawer-body { padding: 12px 18px; overflow-y: auto; flex: 1; font-size: 13px; }",
92
+ ".cnb-kv { display: grid; grid-template-columns: 64px 1fr; gap: 6px 10px; margin: 0 0 14px; font-size: 12.5px; }",
93
+ ".cnb-kv dt { color: var(--dsw-alias-label-tertiary); }",
94
+ ".cnb-kv dd { margin: 0; color: var(--dsw-alias-label-primary); }",
95
+ ".cnb-acts-title { font-size: 11px; font-weight: 700; color: var(--dsw-alias-label-tertiary); text-transform: uppercase; letter-spacing: .05em; margin: 0 0 8px; }",
96
+ ".cnb-drawer-foot { padding: 10px 18px 14px; border-top: 1px solid var(--dsw-alias-border-l1); display: flex; gap: 8px; }",
97
+ ".cnb-drawer-foot .grow { flex: 1; }",
98
+ ".cnb-notice { border-radius: 8px; padding: 8px 12px; font-size: 12.5px; }",
99
+ ".cnb-notice-ok { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-state-success-primary); }",
100
+ ".cnb-notice-err { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-state-error-primary); }",
101
+ ".cnb-hint-block { padding: 18px 16px; font-size: 13px; color: var(--dsw-alias-label-secondary); line-height: 1.8; }",
102
+ ".cnb-hint-block code { font-family: ui-monospace, 'SF Mono', Consolas, monospace; font-size: 12px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); border-radius: 4px; padding: 1px 5px; }"
103
+ ]
104
+
105
+ var style = document.createElement("style")
106
+ style.setAttribute("data-plugin-css", "dsh-cnboard")
107
+ style.textContent = CSS.join("\n")
108
+ document.head.appendChild(style)
109
+
110
+ // ── host 调用封装 ──
111
+ function host(method, body) {
112
+ return fetch("/cnboard-api/" + method, {
113
+ method: "POST",
114
+ headers: { "content-type": "application/json" },
115
+ body: JSON.stringify(body || {})
116
+ })
117
+ .then(function (r) { return r.json() })
118
+ .then(function (d) {
119
+ if (!d || d.ok !== true) {
120
+ var err = new Error((d && d.error && d.error.message) || "请求失败")
121
+ err.code = d && d.error && d.error.code
122
+ err.scopes = d && d.error && d.error.scopes
123
+ throw err
124
+ }
125
+ return d.value
126
+ })
127
+ }
128
+
129
+ // ── 展示元数据 ──
130
+ var STAGES = [
131
+ { key: "todo", name: "待处理", color: "var(--dsw-alias-label-tertiary)" },
132
+ { key: "doing", name: "进行中", color: "var(--dsw-alias-brand-primary)" },
133
+ { key: "review", name: "待评审", color: "var(--dsw-alias-state-business-primary)" },
134
+ { key: "done", name: "已完成", color: "var(--dsw-alias-state-success-primary)" }
135
+ ]
136
+ var TYPE_LABEL = { issue: "Issue", pr: "PR" }
137
+
138
+ function esc(s) {
139
+ return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) {
140
+ return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]
141
+ })
142
+ }
143
+
144
+ function relTime(iso) {
145
+ if (!iso) return ""
146
+ var t = new Date(iso).getTime()
147
+ if (!isFinite(t)) return String(iso)
148
+ var diff = Date.now() - t
149
+ if (diff < 60e3) return "刚刚"
150
+ if (diff < 3600e3) return Math.floor(diff / 60e3) + " 分钟前"
151
+ if (diff < 86400e3) return Math.floor(diff / 3600e3) + " 小时前"
152
+ if (diff < 30 * 86400e3) return Math.floor(diff / 86400e3) + " 天前"
153
+ return new Date(t).toISOString().slice(0, 10)
154
+ }
155
+
156
+ function itemHref(it) {
157
+ return "https://cnb.cool/" + it.repo + "/-/" + (it.type === "issue" ? "issues" : "pulls") + "/" + it.number
158
+ }
159
+
160
+ function avatars(list, cls, key) {
161
+ var shown = (list || []).slice(0, 3)
162
+ return jsx("span", {
163
+ className: "cnb-avatars",
164
+ children: shown.map(function (n, i) {
165
+ return jsx("span", { className: "cnb-avatar " + cls, title: n, children: n.slice(0, 2) }, key + i)
166
+ })
167
+ })
168
+ }
169
+
170
+ function jsx(type, props, key) {
171
+ return reactJsxRuntime.jsx(type, props, key !== undefined ? key : undefined)
172
+ }
173
+
174
+ // ── 组件 ──
175
+
176
+ function ScopeRow(props) {
177
+ var scope = props.scope
178
+ if (!scope || !scope.results) return null
179
+ var keys = Object.keys(scope.results)
180
+ return jsx("div", { className: "cnb-scope-row", children: keys.map(function (k) {
181
+ var r = scope.results[k]
182
+ var label = { mission: "任务集", issues: "Issue", pulls: "PR", build: "流水线", workspace: "云环境" }[k] || k
183
+ var text = label + (r.ok ? " ✓" : " ✗")
184
+ var cls = "cnb-scope " + (r.ok ? "cnb-scope-ok" : "cnb-scope-bad")
185
+ var title = r.ok ? "" : (r.message || "") + (r.missing && r.missing.length ? "(缺 " + r.missing.join(", ") + ")" : "")
186
+ return jsx("span", { className: cls, title: title, children: text }, k)
187
+ }) })
188
+ }
189
+
190
+ function ConfigCard(props) {
191
+ var s = props
192
+ return jsx("div", { className: "cnb-card cnb-config", children: [
193
+ jsx("div", { className: "cnb-config-title", children: "CNB 连接配置" }),
194
+ jsx("div", { className: "cnb-config-row", children: [
195
+ jsx("label", { children: [
196
+ jsx("span", { children: "token" }),
197
+ jsx("input", {
198
+ className: "cnb-input cnb-input-token",
199
+ type: "password",
200
+ placeholder: s.tokenPresent ? "已配置(输入可覆盖)" : "CNB 访问令牌",
201
+ value: s.draftToken,
202
+ onChange: function (e) { s.patch({ draftToken: e.target.value, notice: null }) }
203
+ })
204
+ ] }),
205
+ jsx("label", { children: [
206
+ jsx("span", { children: "任务集" }),
207
+ jsx("input", {
208
+ className: "cnb-input cnb-input-mission",
209
+ placeholder: "org/missions",
210
+ value: s.draftMission,
211
+ onChange: function (e) { s.patch({ draftMission: e.target.value, notice: null }) }
212
+ })
213
+ ] }),
214
+ jsx("button", {
215
+ type: "button",
216
+ className: "cnb-btn cnb-btn-primary",
217
+ onClick: s.save,
218
+ disabled: s.saving,
219
+ children: s.saving ? "保存中…" : "保存并体检"
220
+ })
221
+ ] }),
222
+ jsx("div", { className: "cnb-config-hint", children:
223
+ "仅此两项配置。token 需在 CNB「个人设置 → 访问令牌」勾选 mission-manage 等授权范围;" +
224
+ "看板数据范围在 CNB 任务集设置中维护。token 落盘 " + (s.configFile || "~/.dsh/cnboard.json") +
225
+ ",也可经 profile 配置或环境变量 CNB_TOKEN 提供。"
226
+ }),
227
+ jsx(ScopeRow, { scope: s.scope })
228
+ ] })
229
+ }
230
+
231
+ function Chip(props) {
232
+ return jsx("span", { className: "cnb-chip " + (props.cls || ""), children: props.children }, props.key)
233
+ }
234
+
235
+ function ItemCard(props) {
236
+ var it = props.item
237
+ var prioCls = it.priority === "P0" ? "cnb-chip-p0" : it.priority === "P1" ? "cnb-chip-p1" : ""
238
+ return jsx("button", {
239
+ type: "button",
240
+ className: "cnb-item t-" + it.type,
241
+ onClick: function () { props.open(it) },
242
+ children: [
243
+ jsx("p", { className: "cnb-item-title", children: it.title }),
244
+ jsx("div", { className: "cnb-meta", children: [
245
+ jsx(Chip, { cls: "cnb-chip-" + it.type, children: TYPE_LABEL[it.type] }),
246
+ jsx("span", { className: "cnb-ref", children: it.repo.split("/").pop() + "#" + it.number }),
247
+ it.priority && it.priority !== "未设" ? jsx(Chip, { cls: prioCls, children: it.priority }) : null,
248
+ it.readyForMerge ? jsx(Chip, { cls: "cnb-chip-ok", children: "可合并" }) : null
249
+ ] }),
250
+ it.labels.length ? jsx("div", { className: "cnb-meta", children: it.labels.slice(0, 4).map(function (l, i) {
251
+ return jsx(Chip, { key: "l" + i, children: l })
252
+ }) }) : null,
253
+ jsx("div", { className: "cnb-foot", children: [
254
+ avatars(it.assignees, "cnb-avatar-as", "a" + it.number),
255
+ avatars(it.reviewers, "cnb-avatar-rv", "r" + it.number),
256
+ jsx("span", { className: "grow" }),
257
+ jsx("span", { children: "💬 " + it.commentCount }),
258
+ jsx("span", { children: relTime(it.updatedAt) })
259
+ ] })
260
+ ]
261
+ }, it.id)
262
+ }
263
+
264
+ function Board(props) {
265
+ var groups = props.groups
266
+ var field = props.field
267
+ var items = props.items
268
+ return jsx("div", { className: "cnb-board", children: groups.map(function (g) {
269
+ var list = items.filter(function (d) { return d[field] === g.key })
270
+ return jsx("section", {
271
+ className: "cnb-col",
272
+ key: g.key,
273
+ children: [
274
+ jsx("div", { className: "cnb-col-head", children: [
275
+ jsx("span", { className: "cnb-sw", style: { background: g.color } }),
276
+ jsx("b", { children: g.name }),
277
+ jsx("span", { className: "cnb-col-count", children: String(list.length) })
278
+ ] }),
279
+ jsx("div", { className: "cnb-col-cards", children:
280
+ list.length
281
+ ? list.map(function (it) { return jsx(ItemCard, { item: it, open: props.open }, it.id) })
282
+ : jsx("div", { className: "cnb-empty", children: "空" })
283
+ })
284
+ ]
285
+ })
286
+ }) })
287
+ }
288
+
289
+ function Drawer(props) {
290
+ var it = props.item
291
+ if (!it) return null
292
+ var stage = STAGES.filter(function (s) { return s.key === it.stage })[0]
293
+ return jsx(react.Fragment, { children: [
294
+ jsx("div", { className: "cnb-drawer-scrim", onClick: props.close }),
295
+ jsx("aside", { className: "cnb-drawer", children: [
296
+ jsx("div", { className: "cnb-drawer-head", children: [
297
+ jsx("div", { className: "cnb-meta", children: [
298
+ jsx(Chip, { cls: "cnb-chip-" + it.type, children: TYPE_LABEL[it.type] }),
299
+ jsx("span", { className: "cnb-ref", children: it.repo + "#" + it.number }),
300
+ it.priority !== "未设" ? jsx(Chip, { cls: it.priority === "P0" ? "cnb-chip-p0" : it.priority === "P1" ? "cnb-chip-p1" : "", children: it.priority }) : null
301
+ ] }),
302
+ jsx("h3", { children: it.title })
303
+ ] }),
304
+ jsx("div", { className: "cnb-drawer-body", children: [
305
+ jsx("dl", { className: "cnb-kv", children: [
306
+ jsx("dt", { children: "状态" }, "k1"), jsx("dd", { children: stage ? stage.name : it.stage }, "v1"),
307
+ jsx("dt", { children: "处理人" }, "k2"), jsx("dd", { children: it.assignees.length ? it.assignees.join(", ") : "未分配" }, "v2"),
308
+ it.type === "pr" ? jsx("dt", { children: "评审人" }, "k3") : null,
309
+ it.type === "pr" ? jsx("dd", { children: it.reviewers.length
310
+ ? it.reviewers.map(function (r) { return r + (it.reviewerStates[r] ? "(" + it.reviewerStates[r] + ")" : "") }).join(", ")
311
+ : "—" }, "v3") : null,
312
+ jsx("dt", { children: "标签" }, "k4"), jsx("dd", { children: it.labels.join(", ") || "—" }, "v4"),
313
+ jsx("dt", { children: "评论" }, "k5"), jsx("dd", { children: String(it.commentCount) }, "v5"),
314
+ jsx("dt", { children: "更新" }, "k6"), jsx("dd", { children: relTime(it.updatedAt) }, "v6")
315
+ ] }),
316
+ jsx("p", { className: "cnb-acts-title", children: "操作(下版本开放)" }),
317
+ jsx("div", { className: "cnb-config-row", children: [
318
+ jsx("button", { type: "button", className: "cnb-btn", disabled: true, children: it.type === "pr" ? "合并" : "关闭" }),
319
+ jsx("button", { type: "button", className: "cnb-btn", disabled: true, children: "评论" }),
320
+ jsx("button", { type: "button", className: "cnb-btn", disabled: true, children: "改优先级" }),
321
+ it.type === "pr"
322
+ ? jsx("button", { type: "button", className: "cnb-btn", disabled: true, children: "查看 CI 日志" })
323
+ : jsx("button", { type: "button", className: "cnb-btn", disabled: true, children: "召唤 NPC" })
324
+ ] })
325
+ ] }),
326
+ jsx("div", { className: "cnb-drawer-foot", children: [
327
+ jsx("button", { type: "button", className: "cnb-btn", onClick: props.close, children: "← 返回看板" }),
328
+ jsx("span", { className: "grow" }),
329
+ jsx("a", { href: itemHref(it), target: "_blank", rel: "noopener", style: { textDecoration: "none" },
330
+ children: jsx("button", { type: "button", className: "cnb-btn cnb-btn-primary", children: "在 CNB 打开 ↗" }) })
331
+ ] })
332
+ ] })
333
+ ] })
334
+ }
335
+
336
+ function CnboardSection() {
337
+ var S0 = {
338
+ cfg: null, loading: true, error: null, notice: null,
339
+ data: null, scope: null,
340
+ draftToken: "", draftMission: "", saving: false,
341
+ search: "", type: "all", group: "stage", detail: null
342
+ }
343
+ var stateHook = react.useState(S0)
344
+ var S = stateHook[0]
345
+ var setS = stateHook[1]
346
+ function patch(part) { setS(function (prev) { return Object.assign({}, prev, part) }) }
347
+
348
+ var loadConfig = react.useCallback(function () {
349
+ host("config").then(function (cfg) {
350
+ patch({ cfg: cfg, draftMission: cfg.mission || "", tokenPresent: cfg.tokenPresent, configFile: cfg.configFile })
351
+ }).catch(function (e) { patch({ error: e.message }) })
352
+ }, [])
353
+
354
+ var loadState = react.useCallback(function () {
355
+ patch({ loading: true, error: null })
356
+ host("state").then(function (data) {
357
+ patch({ data: data, loading: false, scope: data.scope || null })
358
+ if (!data.tokenPresent) {
359
+ patch({ error: null })
360
+ } else if (data.warnings && data.warnings.length) {
361
+ patch({ notice: { kind: "err", text: data.warnings[0] } })
362
+ } else {
363
+ patch({ notice: { kind: "ok", text: "已加载 " + data.total + " 项(" + data.mission + ")" } })
364
+ }
365
+ }).catch(function (e) {
366
+ patch({ loading: false, error: e.message, data: null })
367
+ })
368
+ }, [])
369
+
370
+ var runHealthcheck = react.useCallback(function () {
371
+ patch({ loading: true, error: null })
372
+ host("healthcheck").then(function (scope) {
373
+ patch({ scope: scope, loading: false, notice: { kind: "ok", text: "体检完成" } })
374
+ }).catch(function (e) { patch({ loading: false, error: e.message }) })
375
+ }, [])
376
+
377
+ react.useEffect(function () {
378
+ loadConfig()
379
+ loadState()
380
+ }, [])
381
+
382
+ var save = function () {
383
+ patch({ saving: true, notice: null })
384
+ host("saveConfig", { cnbToken: S.draftToken, mission: S.draftMission })
385
+ .then(function () {
386
+ patch({ saving: false, draftToken: "" })
387
+ loadConfig()
388
+ runHealthcheck()
389
+ loadState()
390
+ })
391
+ .catch(function (e) { patch({ saving: false, notice: { kind: "err", text: e.message } }) })
392
+ }
393
+
394
+ var filtered = react.useMemo(function () {
395
+ var d = S.data
396
+ if (!d) return []
397
+ var all = (d.issues || []).concat(d.pull_requests || [])
398
+ var q = S.search.trim().toLowerCase()
399
+ return all.filter(function (it) {
400
+ if (S.type !== "all" && it.type !== S.type) return false
401
+ if (!q) return true
402
+ return it.title.toLowerCase().indexOf(q) >= 0 || it.repo.toLowerCase().indexOf(q) >= 0
403
+ })
404
+ }, [S.data, S.search, S.type])
405
+
406
+ var groups = react.useMemo(function () {
407
+ if (S.group === "priority") {
408
+ var seen = []
409
+ filtered.forEach(function (it) { if (seen.indexOf(it.priority) < 0) seen.push(it.priority) })
410
+ var order = ["P0", "P1", "P2", "P3", "-1P", "-2P", "未设"]
411
+ seen.sort(function (a, b) { return order.indexOf(a) - order.indexOf(b) })
412
+ var colors = { P0: "var(--dsw-alias-state-error-primary)", P1: "var(--dsw-alias-state-warn-primary)", P2: "var(--dsw-alias-brand-primary)", P3: "var(--dsw-alias-label-tertiary)" }
413
+ return seen.map(function (p) { return { key: p, name: p, color: colors[p] || "var(--dsw-alias-label-tertiary)" } })
414
+ }
415
+ if (S.group === "repo") {
416
+ var repos = []
417
+ filtered.forEach(function (it) { if (repos.indexOf(it.repo) < 0) repos.push(it.repo) })
418
+ return repos.map(function (r) { return { key: r, name: r, color: "var(--dsw-alias-brand-primary)" } })
419
+ }
420
+ return STAGES
421
+ }, [S.group, filtered])
422
+
423
+ var field = S.group === "repo" ? "repo" : S.group === "priority" ? "priority" : "stage"
424
+
425
+ var stats = react.useMemo(function () {
426
+ return STAGES.map(function (s) {
427
+ return { s: s, n: filtered.filter(function (it) { return it.stage === s.key }).length }
428
+ })
429
+ }, [filtered])
430
+
431
+ // ── 渲染 ──
432
+ var children = []
433
+
434
+ children.push(jsx(ConfigCard, {
435
+ draftToken: S.draftToken, draftMission: S.draftMission, saving: S.saving,
436
+ tokenPresent: S.cfg ? S.cfg.tokenPresent : false, configFile: S.cfg ? S.cfg.configFile : null,
437
+ scope: S.scope, patch: patch, save: save
438
+ }))
439
+
440
+ if (S.notice) {
441
+ children.push(jsx("div", { className: "cnb-notice " + (S.notice.kind === "ok" ? "cnb-notice-ok" : "cnb-notice-err"), children: S.notice.text }))
442
+ }
443
+ if (S.error) {
444
+ children.push(jsx("div", { className: "cnb-notice cnb-notice-err", children: S.error }))
445
+ }
446
+
447
+ var ready = S.data && S.data.tokenPresent && S.data.total >= 0 && !S.error
448
+ if (ready) {
449
+ children.push(jsx("div", { className: "cnb-toolbar", children: [
450
+ jsx("input", {
451
+ className: "cnb-input cnb-search",
452
+ type: "search",
453
+ placeholder: "搜索标题 / 仓库",
454
+ value: S.search,
455
+ onChange: function (e) { patch({ search: e.target.value }) }
456
+ }),
457
+ jsx("div", { className: "cnb-seg", role: "group", children: ["all", "issue", "pr"].map(function (t) {
458
+ return jsx("button", {
459
+ type: "button",
460
+ "aria-pressed": String(S.type === t),
461
+ onClick: function () { patch({ type: t }) },
462
+ children: t === "all" ? "全部" : TYPE_LABEL[t]
463
+ }, t)
464
+ }) }),
465
+ jsx("select", {
466
+ className: "cnb-select",
467
+ value: S.group,
468
+ onChange: function (e) { patch({ group: e.target.value }) },
469
+ children: [
470
+ jsx("option", { value: "stage", children: "按状态分组" }, "g1"),
471
+ jsx("option", { value: "priority", children: "按优先级分组" }, "g2"),
472
+ jsx("option", { value: "repo", children: "按仓库分组" }, "g3")
473
+ ]
474
+ }),
475
+ jsx("button", { type: "button", className: "cnb-btn", onClick: loadState, disabled: S.loading, children: S.loading ? "刷新中…" : "刷新" })
476
+ ] }))
477
+
478
+ children.push(jsx("div", { className: "cnb-stats", children:
479
+ stats.map(function (x) {
480
+ return jsx("span", { className: "cnb-stat", key: x.s.key, children: [
481
+ jsx("span", { className: "cnb-sw", style: { background: x.s.color } }),
482
+ jsx("span", { children: x.s.name }),
483
+ jsx("b", { children: String(x.n) })
484
+ ] })
485
+ }).concat([jsx("span", { className: "cnb-stat", key: "total", children: ["共 ", jsx("b", { children: String(filtered.length) }), " 项"] })])
486
+ }))
487
+
488
+ if (S.loading && !S.data) {
489
+ children.push(jsx("div", { className: "cnb-empty", children: "加载中…" }))
490
+ } else if (filtered.length === 0) {
491
+ children.push(jsx("div", { className: "cnb-card cnb-hint-block", children: "任务集暂无可展示的 Issue / PR。可检查:①任务集数据范围已关联仓库;②token 的 mission-manage 授权范围;③在 CNB 任务集设置页确认成员与仓库权限。" }))
492
+ } else {
493
+ children.push(jsx(Board, { groups: groups, field: field, items: filtered, open: function (it) { patch({ detail: it }) } }))
494
+ }
495
+
496
+ children.push(jsx("div", { className: "cnb-config-hint", children:
497
+ "数据源:任务集 " + S.data.mission + "(跨仓库 Issue/PR 聚合,经 mission-resource 接口)。" +
498
+ "流水线管理、云环境管理模块规划见仓库 issue #2。"
499
+ }))
500
+ }
501
+
502
+ return jsx("div", { className: "cnb-section", children: [
503
+ jsx("div", { style: { display: "flex", alignItems: "baseline", gap: "8px" }, children: [
504
+ jsx("span", { style: { fontSize: "18px", fontWeight: "600", color: "var(--dsw-alias-label-primary)" }, children: "CNBoard" }),
505
+ jsx("span", { style: { fontSize: "12px", color: "var(--dsw-alias-label-tertiary)" }, children: "CNB 平台任务看板 · 跨组织/跨仓库 Issue 与 PR" })
506
+ ] })
507
+ ].concat(children).concat([
508
+ S.detail ? jsx(Drawer, { item: S.detail, close: function () { patch({ detail: null }) } }) : null
509
+ ]) })
510
+ }
511
+
512
+ // 渲染期兜底:万一组件抛错,把错误显示在面板里而非静默空白
513
+ function SafeSection(props) {
514
+ try {
515
+ return reactJsxRuntime.jsx(CnboardSection, props)
516
+ } catch (e) {
517
+ console.error("[cnboard] render error:", e)
518
+ var msg = String((e && e.message) || e)
519
+ var stack = (e && e.stack) || ""
520
+ return reactJsxRuntime.jsx("div", {
521
+ className: "cnb-notice cnb-notice-err",
522
+ style: { whiteSpace: "pre-wrap", fontFamily: "ui-monospace,monospace", fontSize: "12px" },
523
+ children: "CNBoard 渲染错误: " + msg + "\n\n" + stack
524
+ })
525
+ }
526
+ }
527
+
528
+ function apply(ctx) {
529
+ var slots = ctx.get("slots")
530
+ if (!slots) return
531
+ slots.inject("settings.section", function () {
532
+ return slots.register(
533
+ { name: "settings.section", id: "cnboard", order: 35, label: function () { return "CNBoard" } },
534
+ SafeSection
535
+ )
536
+ })
537
+ }
538
+
539
+ var inject = ["slots"]
540
+
541
+ exports.apply = apply
542
+ exports.inject = inject
543
+ return module.exports
544
+ },
545
+ })
package/lib/index.js ADDED
@@ -0,0 +1,419 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { getClient } from 'node-cnb';
4
+ /** API 路由前缀(client fetch 同源调用)。 */
5
+ const API_PREFIX = '/cnboard-api';
6
+ /** 请求体大小上限(256KB,本插件全部为小 JSON)。 */
7
+ const MAX_BODY_BYTES = 256 * 1024;
8
+ /** 配置落盘位置(dsh home 下,与 profile 解耦)。 */
9
+ const CONFIG_FILE = join('.dsh', 'cnboard.json');
10
+ /** 任务集路径合法性:2~4 段 slug。 */
11
+ const MISSION_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(\/[A-Za-z0-9][A-Za-z0-9._-]*){1,3}$/;
12
+ /** resources 拉取的页数上限(防游标异常导致死循环;正常远小于此)。 */
13
+ const MAX_RESOURCE_PAGES = 20;
14
+ /** 看板卡片聚合上限(超出截断并在返回中标注)。 */
15
+ const MAX_ITEMS = 1000;
16
+ function isRecord(v) {
17
+ return !!v && typeof v === 'object' && !Array.isArray(v);
18
+ }
19
+ function str(v) {
20
+ return typeof v === 'string' ? v : '';
21
+ }
22
+ function names(list) {
23
+ if (!Array.isArray(list))
24
+ return [];
25
+ return list
26
+ .map((u) => (isRecord(u) ? str(u.username) || str(u.nickname) : ''))
27
+ .filter(Boolean);
28
+ }
29
+ /** 从 scope 缺失错误里解析缺失的 scope 列表。 */
30
+ function scopesFromMessage(msg) {
31
+ const m = msg.match(/Missing required scopes:\s*(.+)/i);
32
+ if (!m)
33
+ return [];
34
+ return m[1]
35
+ .split(/[,,]/)
36
+ .map((s) => s.trim())
37
+ .filter(Boolean);
38
+ }
39
+ /** 估算看板阶段(与布局设计 issue #3 的映射一致)。 */
40
+ function stageOf(type, item) {
41
+ const closed = item.state === 'closed' || item.state === 'merged';
42
+ if (type === 'issue') {
43
+ if (closed)
44
+ return 'done';
45
+ return item.started_at ? 'doing' : 'todo';
46
+ }
47
+ if (item.is_merged || closed)
48
+ return 'done';
49
+ return 'review';
50
+ }
51
+ function normalize(type, item) {
52
+ if (!isRecord(item))
53
+ return null;
54
+ const repo = str(item.repo_slug);
55
+ const number = Number(item.number);
56
+ if (!repo || !Number.isFinite(number))
57
+ return null;
58
+ const reviewers = [];
59
+ const reviewerStates = {};
60
+ if (Array.isArray(item.reviewers)) {
61
+ for (const r of item.reviewers) {
62
+ if (!isRecord(r))
63
+ continue;
64
+ const name = str(r.username) || str(r.nickname);
65
+ if (!name)
66
+ continue;
67
+ reviewers.push(name);
68
+ const rs = str(r.review_state);
69
+ if (rs)
70
+ reviewerStates[name] = rs;
71
+ }
72
+ }
73
+ return {
74
+ type,
75
+ repo,
76
+ number,
77
+ id: str(item.id) || `${repo}#${number}`,
78
+ title: str(item.title) || `#${number}`,
79
+ state: str(item.state) || 'open',
80
+ stage: stageOf(type, item),
81
+ priority: str(item.priority) || '未设',
82
+ labels: (Array.isArray(item.labels) ? item.labels : [])
83
+ .map((l) => (isRecord(l) ? str(l.name) : ''))
84
+ .filter(Boolean),
85
+ assignees: names(item.assignees),
86
+ reviewers,
87
+ reviewerStates,
88
+ commentCount: typeof item.comment_count === 'number' ? item.comment_count : 0,
89
+ updatedAt: str(item.updated_at) || str(item.last_acted_at),
90
+ readyForMerge: item.is_ready_for_merge === true,
91
+ mergeableState: str(item.mergeable_state),
92
+ };
93
+ }
94
+ /** CNBoard host 服务:不继承 Service,由 apply 统一实例化。 */
95
+ export class CnboardService {
96
+ ctx;
97
+ baseConfig;
98
+ /** 运行时可变配置(设置页写入 ~/.dsh/cnboard.json 后覆盖 base)。 */
99
+ fileConfig = null;
100
+ clientCache = null;
101
+ clientFor = '';
102
+ scopeCache = null;
103
+ fullAccess = { mode: 'danger-full-access', workspaceRoot: '/' };
104
+ constructor(ctx, config = {}) {
105
+ this.ctx = ctx;
106
+ this.baseConfig = config;
107
+ }
108
+ // ── 配置 ──
109
+ async configPath() {
110
+ return join(homedir(), CONFIG_FILE);
111
+ }
112
+ async loadFileConfig() {
113
+ if (this.fileConfig)
114
+ return this.fileConfig;
115
+ try {
116
+ const target = await this.ctx.fs.resolve(await this.configPath());
117
+ const raw = await this.ctx.fs.readText(target);
118
+ const parsed = JSON.parse(raw);
119
+ if (isRecord(parsed)) {
120
+ this.fileConfig = {
121
+ cnbToken: str(parsed.cnbToken) || undefined,
122
+ mission: str(parsed.mission) || undefined,
123
+ };
124
+ return this.fileConfig;
125
+ }
126
+ }
127
+ catch {
128
+ /* 文件不存在或不可读 → 无文件配置 */
129
+ }
130
+ return null;
131
+ }
132
+ effective() {
133
+ const file = this.fileConfig || {};
134
+ const cnbToken = str(file.cnbToken) || str(this.baseConfig.cnbToken) || process.env.CNB_TOKEN || '';
135
+ const mission = str(file.mission) || str(this.baseConfig.mission) || '';
136
+ const tokenSource = str(file.cnbToken)
137
+ ? 'file'
138
+ : str(this.baseConfig.cnbToken)
139
+ ? 'config'
140
+ : process.env.CNB_TOKEN
141
+ ? 'env'
142
+ : 'none';
143
+ return { cnbToken, mission, tokenSource };
144
+ }
145
+ // ── CNB 客户端 ──
146
+ client() {
147
+ const { cnbToken, mission } = this.effective();
148
+ if (!cnbToken)
149
+ throw this.cnbError(0, 'NO_TOKEN', '尚未配置 CNB token,请在下方配置区填写');
150
+ if (!MISSION_RE.test(mission))
151
+ throw this.cnbError(0, 'NO_MISSION', '任务集路径未配置或不合法(形如 org/missions)');
152
+ const key = cnbToken;
153
+ if (!this.clientCache || this.clientFor !== key) {
154
+ this.clientCache = getClient('https://api.cnb.cool', cnbToken);
155
+ this.clientFor = key;
156
+ this.scopeCache = null;
157
+ }
158
+ return this.clientCache;
159
+ }
160
+ cnbError(status, code, message, scopes) {
161
+ const err = new Error(message);
162
+ err.cnb = { status, errcode: undefined, errmsg: message, scopes: scopes ?? (code === 'SCOPE' ? [] : undefined) };
163
+ return err;
164
+ }
165
+ /**
166
+ * 统一 CNB 调用:node-cnb 实测 4xx/5xx 抛 ky HTTPError(README 的
167
+ * isDieWebError 结构化返回不成立),在此转成带 cnb 信息的普通 Error。
168
+ */
169
+ async cnbCall(fn) {
170
+ try {
171
+ return await fn();
172
+ }
173
+ catch (e) {
174
+ const anyE = e;
175
+ let status;
176
+ let body;
177
+ try {
178
+ status = anyE?.response?.status;
179
+ const text = anyE?.response?.text ? await anyE.response.text() : '';
180
+ if (text)
181
+ body = JSON.parse(text);
182
+ }
183
+ catch {
184
+ /* 保留原始错误信息 */
185
+ }
186
+ const err = new Error(body?.errmsg || anyE?.message || 'CNB API 调用失败');
187
+ err.cnb = {
188
+ status,
189
+ errcode: body?.errcode,
190
+ errmsg: body?.errmsg,
191
+ scopes: body?.errcode === 10023 ? scopesFromMessage(body?.errmsg || '') : undefined,
192
+ };
193
+ throw err;
194
+ }
195
+ }
196
+ isScopeError(e) {
197
+ return !!(e && typeof e === 'object' && e.cnb?.errcode === 10023);
198
+ }
199
+ // ── 对外方法(client 调用) ──
200
+ /** scope 体检:五类只读端点逐项探测。pulls/build 探测用 token 可见的第一个仓库。 */
201
+ async healthcheck() {
202
+ const { mission } = this.effective();
203
+ let probeRepo = '';
204
+ try {
205
+ const repos = await this.cnbCall(() => this.client().user.repos.list({ page: 1, page_size: 1 }));
206
+ const first = Array.isArray(repos) ? repos[0] : undefined;
207
+ if (first && typeof first.path === 'string')
208
+ probeRepo = first.path;
209
+ }
210
+ catch {
211
+ /* 取不到仓库时 pulls/build 探测降级为不可判定 */
212
+ }
213
+ const fallbackRepo = probeRepo || mission.split('/').slice(0, 2).join('/');
214
+ const probes = [
215
+ {
216
+ key: 'mission',
217
+ label: '任务集 mission-manage:r',
218
+ run: () => this.client().mission.missionResource.fields.list({ mission, type: 'issues' }),
219
+ },
220
+ { key: 'issues', label: 'Issue 读', run: () => this.client().user.issues.list({ filter: 'assigned', page: 1, page_size: 1 }) },
221
+ {
222
+ key: 'pulls',
223
+ label: 'PR 读',
224
+ run: () => this.client().repo.pulls.list({ repo: fallbackRepo, page: 1, page_size: 1 }),
225
+ },
226
+ { key: 'build', label: '流水线读(M2 预备)', run: () => this.client().repo.build.logs.get({ repo: fallbackRepo, page: 1, page_size: 1 }) },
227
+ { key: 'workspace', label: '云环境读(M3 预备)', run: () => this.client().workspace.list.get({ page: 1, page_size: 1 }) },
228
+ ];
229
+ const results = {};
230
+ for (const p of probes) {
231
+ try {
232
+ await this.cnbCall(p.run);
233
+ results[p.key] = { ok: true };
234
+ }
235
+ catch (e) {
236
+ if (this.isScopeError(e)) {
237
+ results[p.key] = { ok: false, missing: e.cnb.scopes ?? [], message: e.cnb.errmsg };
238
+ }
239
+ else {
240
+ results[p.key] = { ok: false, message: e instanceof Error ? e.message : String(e) };
241
+ }
242
+ }
243
+ }
244
+ const out = { checkedAt: new Date().toISOString(), results };
245
+ this.scopeCache = { at: Date.now(), result: out };
246
+ return out;
247
+ }
248
+ /** 看板聚合:任务集 fields 元数据 + resources 全量(游标翻页)。 */
249
+ async state() {
250
+ const { mission } = this.effective();
251
+ const client = this.client();
252
+ const warnings = [];
253
+ let fields = null;
254
+ try {
255
+ const [issueFields, prFields] = await Promise.all([
256
+ this.cnbCall(() => client.mission.missionResource.fields.list({ mission, type: 'issues' })).catch((e) => {
257
+ warnings.push(`issue 属性元数据获取失败: ${e instanceof Error ? e.message : e}`);
258
+ return null;
259
+ }),
260
+ this.cnbCall(() => client.mission.missionResource.fields.list({ mission, type: 'pull_requests' })).catch((e) => {
261
+ warnings.push(`PR 属性元数据获取失败: ${e instanceof Error ? e.message : e}`);
262
+ return null;
263
+ }),
264
+ ]);
265
+ fields = { issues: issueFields, pull_requests: prFields };
266
+ }
267
+ catch {
268
+ fields = null;
269
+ }
270
+ const issues = [];
271
+ const pullRequests = [];
272
+ let cursor = '';
273
+ let truncated = false;
274
+ for (let page = 0; page < MAX_RESOURCE_PAGES; page++) {
275
+ const res = (await this.cnbCall(() => client.mission.missionResource.resources.post({
276
+ mission,
277
+ cursor: cursor || undefined,
278
+ // node-cnb 的 body 参数会把 params.selectors 的键值展开为请求体,
279
+ // 请求体结构为 handler.MissionFilterFrom = { fields, selectors }
280
+ selectors: { fields: [], selectors: [] },
281
+ })));
282
+ for (const raw of res.issues || []) {
283
+ const item = normalize('issue', raw);
284
+ if (item)
285
+ issues.push(item);
286
+ }
287
+ for (const raw of res.pull_requests || []) {
288
+ const item = normalize('pr', raw);
289
+ if (item)
290
+ pullRequests.push(item);
291
+ }
292
+ if (!res.next_cursor)
293
+ break;
294
+ cursor = res.next_cursor;
295
+ if (issues.length + pullRequests.length >= MAX_ITEMS) {
296
+ truncated = true;
297
+ break;
298
+ }
299
+ }
300
+ const missionInfo = await this.cnbCall(() => client.mission.missionResource.fields.list({ mission, type: 'issues' }).then(() => null)).catch(() => null);
301
+ return {
302
+ mission,
303
+ tokenSource: this.effective().tokenSource,
304
+ tokenPresent: !!this.effective().cnbToken,
305
+ fields,
306
+ missionInfo,
307
+ issues,
308
+ pull_requests: pullRequests,
309
+ total: issues.length + pullRequests.length,
310
+ truncated,
311
+ warnings,
312
+ scope: this.scopeCache?.result ?? null,
313
+ };
314
+ }
315
+ /** 保存设置页写入的配置(落盘 ~/.dsh/cnboard.json)。 */
316
+ async saveConfig(args) {
317
+ const cnbToken = str(args.cnbToken).trim();
318
+ const mission = str(args.mission).trim();
319
+ if (mission && !MISSION_RE.test(mission)) {
320
+ return { ok: false, error: '任务集路径不合法,应为 org/missions 形式(2~4 段)' };
321
+ }
322
+ if (cnbToken && (cnbToken.length < 16 || cnbToken.length > 128)) {
323
+ return { ok: false, error: 'token 长度不合法(16~128 字符)' };
324
+ }
325
+ const path = await this.configPath();
326
+ const target = await this.ctx.fs.resolve(path);
327
+ await this.ctx.fs.writeText(target, JSON.stringify({ cnbToken, mission }, null, 2) + '\n', undefined, undefined, this.fullAccess);
328
+ this.fileConfig = { cnbToken: cnbToken || undefined, mission: mission || undefined };
329
+ this.clientCache = null;
330
+ this.scopeCache = null;
331
+ return { ok: true, saved: path };
332
+ }
333
+ /** 当前生效配置(token 只回传是否存在与来源,不回传原文)。 */
334
+ async config() {
335
+ const eff = this.effective();
336
+ return { mission: eff.mission, tokenSource: eff.tokenSource, tokenPresent: !!eff.cnbToken, configFile: await this.configPath() };
337
+ }
338
+ }
339
+ // ── HTTP 工具 ──
340
+ async function readJsonBody(req) {
341
+ const chunks = [];
342
+ let total = 0;
343
+ for await (const chunk of req) {
344
+ const buffer = Buffer.from(chunk);
345
+ total += buffer.length;
346
+ if (total > MAX_BODY_BYTES)
347
+ throw new Error('request body too large');
348
+ chunks.push(buffer);
349
+ }
350
+ const text = Buffer.concat(chunks).toString('utf8');
351
+ if (text.trim() === '')
352
+ return {};
353
+ try {
354
+ const parsed = JSON.parse(text);
355
+ return parsed && typeof parsed === 'object' ? parsed : {};
356
+ }
357
+ catch {
358
+ throw new Error('request body is not valid JSON');
359
+ }
360
+ }
361
+ function writeJson(res, status, body) {
362
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
363
+ res.end(JSON.stringify(body));
364
+ }
365
+ /**
366
+ * 插件主体:注册 /cnboard-api/* JSON 路由并实例化服务。
367
+ * 安全立场与 dsh-skills-manager 一致:宿主 webserver 的既有信任边界内使用,
368
+ * POST 仅接受白名单方法、有界 JSON body;token 不做日志输出。
369
+ */
370
+ export function apply(ctx, config = {}) {
371
+ const service = new CnboardService(ctx, config);
372
+ const allowed = new Set(['state', 'healthcheck', 'saveConfig', 'config']);
373
+ const webServer = ctx.get('webServer');
374
+ if (!webServer) {
375
+ ctx.logger?.warn('[cnboard] webServer service unavailable, /cnboard-api routes not registered');
376
+ return;
377
+ }
378
+ ctx.effect(() => webServer.register({
379
+ kind: 'prefix',
380
+ path: API_PREFIX,
381
+ handler: async (req, res) => {
382
+ if (req.method !== 'POST') {
383
+ writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } });
384
+ return;
385
+ }
386
+ const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname;
387
+ const method = pathname.startsWith(`${API_PREFIX}/`) ? pathname.slice(API_PREFIX.length + 1) : undefined;
388
+ if (!method || method.includes('/') || !allowed.has(method)) {
389
+ writeJson(res, 404, { ok: false, error: { code: 'not-found', message: `unknown cnboard API method "${method ?? ''}"` } });
390
+ return;
391
+ }
392
+ try {
393
+ const payload = await readJsonBody(req);
394
+ const handler = service[method];
395
+ if (typeof handler !== 'function') {
396
+ writeJson(res, 404, { ok: false, error: { code: 'not-found', message: `unknown cnboard API method "${method}"` } });
397
+ return;
398
+ }
399
+ const result = await handler.call(service, payload);
400
+ writeJson(res, 200, { ok: true, value: result });
401
+ }
402
+ catch (error) {
403
+ const cnb = error?.cnb;
404
+ writeJson(res, 200, {
405
+ ok: false,
406
+ error: {
407
+ code: cnb?.errcode === 10023 ? 'scope' : cnb?.errcode === 0 || cnb?.status === undefined ? 'config' : 'cnb',
408
+ message: error instanceof Error ? error.message : String(error),
409
+ scopes: cnb?.scopes,
410
+ status: cnb?.status,
411
+ errcode: cnb?.errcode,
412
+ },
413
+ });
414
+ }
415
+ },
416
+ }), 'cnboard: /cnboard-api routes');
417
+ }
418
+ export const name = 'dsh-cnboard';
419
+ export const inject = ['fs', 'webServer'];
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "dsh-cnboard",
3
+ "description": "dshweb 的 CNB 平台管理插件:以任务集为数据源,跨组织/跨仓库聚合 Issue/PR 任务看板;规划流水线与云环境管理。",
4
+ "version": "0.1.0",
5
+ "homepage": "https://cnb.cool/xqitw/dsh-cnboard",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://cnb.cool/xqitw/dsh-cnboard.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://cnb.cool/xqitw/dsh-cnboard/-/issues"
12
+ },
13
+ "keywords": [
14
+ "dsh",
15
+ "deepseek-harness",
16
+ "cnb",
17
+ "mission",
18
+ "kanban",
19
+ "board",
20
+ "dsh-plugin",
21
+ "cordis"
22
+ ],
23
+ "engines": {
24
+ "node": ">=20"
25
+ },
26
+ "type": "module",
27
+ "main": "lib/index.js",
28
+ "exports": {
29
+ ".": "./lib/index.js",
30
+ "./client": "./lib/client.js",
31
+ "./cordis.patch.yml": "./cordis.patch.yml",
32
+ "./package.json": "./package.json"
33
+ },
34
+ "files": [
35
+ "lib/index.js",
36
+ "lib/client.js",
37
+ "cordis.patch.yml",
38
+ "README.md",
39
+ "LICENSE",
40
+ "docs/"
41
+ ],
42
+ "license": "MIT",
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "scripts": {
47
+ "build": "tsc -p tsconfig.json",
48
+ "lint:md": "markdownlint-cli2 '**/*.md'"
49
+ },
50
+ "dependencies": {
51
+ "node-cnb": "^1.36.0"
52
+ },
53
+ "peerDependencies": {
54
+ "@deepseek-ai/cordis": "^4.0.1"
55
+ },
56
+ "devDependencies": {
57
+ "@deepseek-ai/cordis": "^4.0.1",
58
+ "@deepseek-ai/dsh-fs": "^0.0.1-rc.1",
59
+ "@deepseek-ai/dsh-host-webserver": "^0.0.1-rc.1",
60
+ "@types/node": "^20.0.0",
61
+ "markdownlint-cli2": "^0.17.0",
62
+ "typescript": "^5.0.0"
63
+ }
64
+ }