@lyra-ai/toolkit 0.2.2 → 0.2.4
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/QUICKSTART.md +93 -1
- package/README.md +40 -1
- package/bin/skillhub.mjs +4 -0
- package/bin/toolkit.mjs +24 -16
- package/package.json +4 -2
- package/src/config-cmd.mjs +32 -39
- package/src/flush.mjs +3 -14
- package/src/install.mjs +10 -14
- package/src/logquery.mjs +5 -25
- package/src/shared/config.mjs +125 -0
- package/src/skillhub.mjs +5836 -0
- package/src/status.mjs +10 -11
- package/src/zentao.mjs +233 -119
package/src/status.mjs
CHANGED
|
@@ -5,14 +5,15 @@
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import os from 'node:os';
|
|
8
|
+
import { load as loadConfig, UPLOADED_PATH } from './shared/config.mjs';
|
|
8
9
|
|
|
9
10
|
const HOME = os.homedir();
|
|
10
11
|
const PLUGIN_DIR = path.join(HOME, '.claude', 'plugins', 'toolkit');
|
|
11
|
-
const TOOLKIT_DIR = path.join(HOME, '.toolkit');
|
|
12
12
|
|
|
13
13
|
export function status() {
|
|
14
14
|
const pluginExists = fs.existsSync(path.join(PLUGIN_DIR, '.claude-plugin', 'plugin.json'));
|
|
15
|
-
const
|
|
15
|
+
const config = loadConfig();
|
|
16
|
+
const configExists = !!config.registry || !!config.uid;
|
|
16
17
|
|
|
17
18
|
console.log('Toolkit Status');
|
|
18
19
|
console.log('════════════════════════════════════════');
|
|
@@ -29,28 +30,26 @@ export function status() {
|
|
|
29
30
|
console.log(` 版本: ${pj.version}`);
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
// 配置
|
|
33
|
+
// 配置(走 shared/config.mjs)
|
|
33
34
|
if (configExists) {
|
|
34
|
-
const config = JSON.parse(fs.readFileSync(path.join(TOOLKIT_DIR, 'config.json'), 'utf-8'));
|
|
35
35
|
console.log(` 平台地址: ${config.registry}${config.contexts?.metrics || '/ai-metrics'}/`);
|
|
36
36
|
console.log(` 身份 UID: ${config.uid?.slice(0, 12)}...`);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
// 上传状态
|
|
40
|
-
const uploadedPath = path.join(TOOLKIT_DIR, 'uploaded.json');
|
|
41
40
|
try {
|
|
42
|
-
const up = JSON.parse(fs.readFileSync(
|
|
43
|
-
console.log(`
|
|
44
|
-
console.log(`
|
|
41
|
+
const up = JSON.parse(fs.readFileSync(UPLOADED_PATH, 'utf-8'));
|
|
42
|
+
console.log(` 已同步: ${up.uploaded_sids?.length || 0} 个会话`);
|
|
43
|
+
console.log(` 上次同步: ${up.last_flush || '从未'}`);
|
|
45
44
|
} catch {
|
|
46
|
-
console.log('
|
|
45
|
+
console.log(' 同步状态: 未初始化');
|
|
47
46
|
}
|
|
48
47
|
|
|
49
48
|
// 待传会话计数
|
|
50
49
|
const projectsDir = path.join(HOME, '.claude', 'projects');
|
|
51
50
|
let pending = 0;
|
|
52
51
|
if (fs.existsSync(projectsDir) && configExists) {
|
|
53
|
-
const up = JSON.parse(fs.readFileSync(
|
|
52
|
+
const up = JSON.parse(fs.readFileSync(UPLOADED_PATH, 'utf-8'));
|
|
54
53
|
const uploadedSet = new Set(up.uploaded_sids || []);
|
|
55
54
|
for (const projDir of fs.readdirSync(projectsDir)) {
|
|
56
55
|
const pp = path.join(projectsDir, projDir);
|
|
@@ -62,6 +61,6 @@ export function status() {
|
|
|
62
61
|
} catch {}
|
|
63
62
|
}
|
|
64
63
|
}
|
|
65
|
-
console.log(`
|
|
64
|
+
console.log(` 待同步: ${pending} 个会话`);
|
|
66
65
|
console.log('════════════════════════════════════════');
|
|
67
66
|
}
|
package/src/zentao.mjs
CHANGED
|
@@ -46,23 +46,25 @@ const HELP = `zentao-cli —— 面向 AI agent 的禅道 CLI
|
|
|
46
46
|
命令:
|
|
47
47
|
project list [--status active|closed|all] [--limit N] [--offset N] [--pretty]
|
|
48
48
|
product list [--status active|closed|all] [--limit N] [--offset N] [--pretty]
|
|
49
|
+
build list [--project <ID|名称前缀>] [--limit N] [--offset N] [--pretty] # 列出项目版本(builds)
|
|
49
50
|
bug list [--title <kw>] [--status S] [--severity 1-4] [--priority 1-4]
|
|
50
51
|
[--assigned-to U] [--opened-by U] [--mine]
|
|
51
52
|
[--product <ID|名称前缀>] [--project ID] [--start YYYY-MM-DD] [--end YYYY-MM-DD]
|
|
52
53
|
[--order id_desc|openedDate_desc|severity_desc|priority_desc|...]
|
|
53
54
|
[--limit N] [--offset N] [--pretty]
|
|
54
|
-
#
|
|
55
|
+
# 仅在本地工作集内检索(需先 bug sync);bug search 等价别名
|
|
55
56
|
bug get <bugId> [--pretty|--raw]
|
|
56
57
|
bug create --title T [--product ID] [--severity 1-4] [--priority 1-4]
|
|
57
|
-
[--assigned-to U] [--type code] [--steps ...] [--pretty]
|
|
58
|
+
[--assigned-to U] [--type code] [--build B] [--steps ...] [--pretty]
|
|
58
59
|
bug resolve <bugId> --resolution <r> [--build B] [--comment ...]
|
|
59
60
|
r ∈ {${RESOLUTIONS.join(',')}}
|
|
60
61
|
bug assign <bugId> --to <user>
|
|
61
|
-
bug
|
|
62
|
+
bug confirm <bugId> [--comment ...] [--assigned-to U] [--priority 1-4] [--type T] # 确认 Bug
|
|
63
|
+
bug sync [--product <ID|名称前缀>] [--status <browseType>] [--mine] # 同步产品 bug 到本地工作集(整体替换,默认 unclosed)
|
|
62
64
|
bug browse-types # 列出 sync --status 的合法 browseType
|
|
63
65
|
config 列出所有配置(密码脱敏)
|
|
64
66
|
config get <key> 取单个配置值(ZENTAO_PASSWORD 需 --raw 才显原文)
|
|
65
|
-
config set <key> <v> 写入 ~/.
|
|
67
|
+
config set <key> <v> 写入 ~/.toolkit/config.json(同时写 .env 兼容)
|
|
66
68
|
config unset <key> 删除配置项
|
|
67
69
|
config path 输出配置文件路径
|
|
68
70
|
init 向导式初始化配置(交互提示填 URL/用户/密码等)
|
|
@@ -71,8 +73,8 @@ const HELP = `zentao-cli —— 面向 AI agent 的禅道 CLI
|
|
|
71
73
|
ping 验证连通性 + 凭据
|
|
72
74
|
|
|
73
75
|
通用: --pretty(人读) --raw(原始 JSON) 默认输出规整 JSON
|
|
74
|
-
配置:
|
|
75
|
-
|
|
76
|
+
配置: 统一存 ~/.toolkit/config.json → zentao 段(也支持 ~/.zentao-cli/.env 兼容)
|
|
77
|
+
toolkit config set zentao.url <url> / zentao.username <u> / zentao.password <p>
|
|
76
78
|
`;
|
|
77
79
|
|
|
78
80
|
function fileTokenStore(cachePath) {
|
|
@@ -91,6 +93,15 @@ function loadFilesText() {
|
|
|
91
93
|
return files; // 项目级在前(优先级高)
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
// 从 ~/.toolkit/config.json → zentao 段读配置(优先级高于 .env)
|
|
97
|
+
function loadToolkitZentaoConfig() {
|
|
98
|
+
try {
|
|
99
|
+
const configPath = path.join(os.homedir(), '.toolkit', 'config.json');
|
|
100
|
+
const raw = fs.readFileSync(configPath, 'utf8');
|
|
101
|
+
return JSON.parse(raw).zentao || {};
|
|
102
|
+
} catch { return {}; }
|
|
103
|
+
}
|
|
104
|
+
|
|
94
105
|
function main() {
|
|
95
106
|
const argv = process.argv.slice(2);
|
|
96
107
|
const parsed = parseArgs(argv);
|
|
@@ -139,18 +150,28 @@ export function parseEnvFile(text) {
|
|
|
139
150
|
|
|
140
151
|
const REQUIRED = ['ZENTAO_URL', 'ZENTAO_USERNAME', 'ZENTAO_PASSWORD'];
|
|
141
152
|
|
|
142
|
-
export function loadConfig({ env, filesText }, { required } = {}) {
|
|
143
|
-
//
|
|
153
|
+
export function loadConfig({ env, filesText, toolkitZentao }, { required } = {}) {
|
|
154
|
+
// 优先级(高→低): OS env > 项目 .env > 用户 .env > ~/.toolkit/config.json → zentao 段
|
|
144
155
|
const merged = {};
|
|
156
|
+
|
|
157
|
+
// 1. toolkit config.json → zentao 段(最低优先级,作为底)
|
|
158
|
+
const tkZentao = toolkitZentao !== undefined ? toolkitZentao : loadToolkitZentaoConfig();
|
|
159
|
+
for (const [camel, upper] of Object.entries(ZENTAO_KEY_MAP)) {
|
|
160
|
+
if (tkZentao[camel] != null) merged[upper] = String(tkZentao[camel]);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 2. .env 文件(项目级 > 用户级)
|
|
145
164
|
for (let i = filesText.length - 1; i >= 0; i--) {
|
|
146
165
|
Object.assign(merged, parseEnvFile(filesText[i]));
|
|
147
166
|
}
|
|
148
|
-
|
|
167
|
+
|
|
168
|
+
// 3. OS env(最高优先级)
|
|
169
|
+
Object.assign(merged, env);
|
|
149
170
|
|
|
150
171
|
const requiredKeys = required !== false ? REQUIRED : [];
|
|
151
172
|
const missing = requiredKeys.filter((k) => !merged[k]);
|
|
152
173
|
if (missing.length) {
|
|
153
|
-
throw new ZentaoError('CONFIG_ERROR',
|
|
174
|
+
throw new ZentaoError('CONFIG_ERROR', `缺少必需配置: ${missing.join(', ')}(toolkit config set zentao.url / zentao.username / zentao.password 或编辑 .env)`);
|
|
154
175
|
}
|
|
155
176
|
return {
|
|
156
177
|
zentaoUrl: String(merged.ZENTAO_URL || '').replace(/\/+$/, ''),
|
|
@@ -165,12 +186,27 @@ export function loadConfig({ env, filesText }, { required } = {}) {
|
|
|
165
186
|
// ---- config 读写(操作 ~/.zentao-cli/.env)----
|
|
166
187
|
|
|
167
188
|
export function configFilePath() {
|
|
189
|
+
// .env 文件路径(兼容读写);toolkit config.json 通过 loadToolkitZentaoConfig 读
|
|
168
190
|
return path.join(os.homedir(), '.zentao-cli', '.env');
|
|
169
191
|
}
|
|
170
192
|
|
|
193
|
+
export function toolkitConfigPath() {
|
|
194
|
+
return path.join(os.homedir(), '.toolkit', 'config.json');
|
|
195
|
+
}
|
|
196
|
+
|
|
171
197
|
// 已知配置键(用于 list 展示顺序与 set 校验提示)
|
|
172
198
|
const KNOWN_KEYS = ['ZENTAO_URL', 'ZENTAO_USERNAME', 'ZENTAO_PASSWORD', 'ZENTAO_DEFAULT_PROJECT', 'ZENTAO_TOKEN_TTL', 'ZENTAO_LOG_LEVEL', 'ZENTAO_CACHE_STALE_MS'];
|
|
173
199
|
|
|
200
|
+
// toolkit config.json 键名映射(camelCase ↔ UPPER_CASE)
|
|
201
|
+
const ZENTAO_KEY_MAP = {
|
|
202
|
+
'url': 'ZENTAO_URL',
|
|
203
|
+
'username': 'ZENTAO_USERNAME',
|
|
204
|
+
'password': 'ZENTAO_PASSWORD',
|
|
205
|
+
'default-project': 'ZENTAO_DEFAULT_PROJECT',
|
|
206
|
+
'token-ttl': 'ZENTAO_TOKEN_TTL',
|
|
207
|
+
'cache-stale-ms': 'ZENTAO_CACHE_STALE_MS',
|
|
208
|
+
};
|
|
209
|
+
|
|
174
210
|
// 敏感键:list/get 默认脱敏
|
|
175
211
|
const SENSITIVE_KEYS = new Set(['ZENTAO_PASSWORD']);
|
|
176
212
|
|
|
@@ -273,6 +309,16 @@ export function shapeProduct(p) {
|
|
|
273
309
|
};
|
|
274
310
|
}
|
|
275
311
|
|
|
312
|
+
// 版本(build):依据 禅道APIv1.0.md §2.11 GET /projects/{id}/builds
|
|
313
|
+
export function shapeBuild(b) {
|
|
314
|
+
return {
|
|
315
|
+
id: b.id, name: b.name,
|
|
316
|
+
project: b.project ?? '', product: b.product ?? '',
|
|
317
|
+
execution: b.execution ?? '', branch: b.branch ?? '',
|
|
318
|
+
date: b.date ?? '', builder: b.builder ?? '', desc: b.desc ?? '',
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
276
322
|
export function shapeBugListItem(b) {
|
|
277
323
|
return {
|
|
278
324
|
id: b.id, title: b.title, severity: b.severity, priority: b.pri,
|
|
@@ -311,6 +357,15 @@ export function productsToMarkdown({ total, count, products }) {
|
|
|
311
357
|
return lines.join('\n');
|
|
312
358
|
}
|
|
313
359
|
|
|
360
|
+
export function buildsToMarkdown({ total, count, builds }) {
|
|
361
|
+
const lines = ['# Builds List', '', `Found ${total} builds (showing ${count})`, '',
|
|
362
|
+
'| ID | Name | Project | Product | Date | Builder | Desc |', '|---|---|---|---|---|---|---|'];
|
|
363
|
+
for (const b of builds) {
|
|
364
|
+
lines.push(`| ${b.id} | ${b.name} | ${b.project} | ${b.product} | ${b.date} | ${b.builder} | ${b.desc} |`);
|
|
365
|
+
}
|
|
366
|
+
return lines.join('\n');
|
|
367
|
+
}
|
|
368
|
+
|
|
314
369
|
export function bugListToMarkdown({ total, count, bugs }) {
|
|
315
370
|
const lines = ['# Bug List', '', `Found ${total} bugs (showing ${count})`, '',
|
|
316
371
|
'| ID | Title | Sev | Pri | Status | AssignedTo | OpenedBy | OpenedDate |', '|---|---|---|---|---|---|---|---|'];
|
|
@@ -499,9 +554,23 @@ function cmdConfigSet(parsed, filesystem) {
|
|
|
499
554
|
const key = parsed.positional[0];
|
|
500
555
|
const value = parsed.positional[1];
|
|
501
556
|
if (!key || value === undefined) return { stderr: '用法:zentao config set <key> <value>', exitCode: 2 };
|
|
502
|
-
|
|
557
|
+
// 接受 ZENTAO_URL 或 url 或 URL 两种写法
|
|
558
|
+
const keyUpper = key.toUpperCase().replace(/^ZENTAO_/, 'ZENTAO_').startsWith('ZENTAO_') ? key.toUpperCase() : `ZENTAO_${key.toUpperCase()}`;
|
|
503
559
|
if (!KNOWN_KEYS.includes(keyUpper)) {
|
|
504
|
-
return { stderr: `未知配置项: ${key}(合法值: ${KNOWN_KEYS.join(', ')}
|
|
560
|
+
return { stderr: `未知配置项: ${key}(合法值: ${KNOWN_KEYS.join(', ')})。也可用 toolkit config set zentao.url <value>`, exitCode: 2 };
|
|
561
|
+
}
|
|
562
|
+
// 优先写 ~/.toolkit/config.json → zentao 段;同时也写 .env(兼容旧逻辑)
|
|
563
|
+
const camelKey = Object.entries(ZENTAO_KEY_MAP).find(([_, u]) => u === keyUpper)?.[0];
|
|
564
|
+
if (camelKey) {
|
|
565
|
+
try {
|
|
566
|
+
const configPath = path.join(os.homedir(), '.toolkit', 'config.json');
|
|
567
|
+
let tkConfig = {};
|
|
568
|
+
try { tkConfig = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch {}
|
|
569
|
+
if (!tkConfig.zentao) tkConfig.zentao = {};
|
|
570
|
+
tkConfig.zentao[camelKey] = value;
|
|
571
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
572
|
+
fs.writeFileSync(configPath, JSON.stringify(tkConfig, null, 2));
|
|
573
|
+
} catch { /* 写 toolkit config 失败不影响 .env */ }
|
|
505
574
|
}
|
|
506
575
|
setConfigKey(keyUpper, value, filesystem);
|
|
507
576
|
return { stdout: JSON.stringify({ set: keyUpper, value: maskIfSensitive(keyUpper, value) }, null, 2) };
|
|
@@ -516,7 +585,7 @@ function cmdConfigUnset(parsed, filesystem) {
|
|
|
516
585
|
}
|
|
517
586
|
|
|
518
587
|
function cmdConfigPath() {
|
|
519
|
-
return { stdout: configFilePath() };
|
|
588
|
+
return { stdout: toolkitConfigPath() + ' (zentao 段) / ' + configFilePath() + ' (.env 兼容)' };
|
|
520
589
|
}
|
|
521
590
|
|
|
522
591
|
// ---- init 向导 ----
|
|
@@ -656,43 +725,49 @@ async function cmdProductList(parsed, client) {
|
|
|
656
725
|
return { stdout: JSON.stringify(out, null, 2) };
|
|
657
726
|
}
|
|
658
727
|
|
|
659
|
-
//
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
//
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
const matches =
|
|
728
|
+
// 列出项目版本(builds):依据 禅道APIv1.0.md §2.11.1 GET /projects/{id}/builds
|
|
729
|
+
async function cmdBuildList(parsed, client, config) {
|
|
730
|
+
let projectId = parsed.flags.project || config.defaultProject;
|
|
731
|
+
if (!projectId) throw new ZentaoError('INVALID_PARAMETER', '未指定 --project 且无 ZENTAO_DEFAULT_PROJECT');
|
|
732
|
+
// 名称前缀解析 + 存在性校验(与 sync/create 一致)
|
|
733
|
+
const projectList = await client.request('GET', '/api.php/v1/projects');
|
|
734
|
+
const projects = projectList.projects || [];
|
|
735
|
+
const knownIds = new Set(projects.map((p) => String(p.id)));
|
|
736
|
+
if (!/^\d+$/.test(String(projectId))) {
|
|
737
|
+
const matches = projects.filter((p) => p.name && String(p.name).startsWith(projectId));
|
|
669
738
|
if (!matches.length) {
|
|
670
|
-
const names =
|
|
671
|
-
|
|
739
|
+
const names = projects.slice(0, 20).map((p) => p.name).filter(Boolean).join(', ');
|
|
740
|
+
throw new ZentaoError('INVALID_PARAMETER', `未找到名前缀匹配 "${projectId}" 的项目。已知项目: ${names || '(无)'}`);
|
|
672
741
|
}
|
|
673
|
-
|
|
674
|
-
}
|
|
675
|
-
const products = wantProduct ? [String(wantProduct)] : listCacheProducts(fs, cacheDir);
|
|
676
|
-
if (!products.length) {
|
|
677
|
-
return { stderr: '无本地缓存,请先:zentao bug sync --product <ID>', exitCode: 2 };
|
|
742
|
+
projectId = matches[0].id;
|
|
678
743
|
}
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
let oldestSync = Infinity;
|
|
682
|
-
let missing = false;
|
|
683
|
-
for (const pid of products) {
|
|
684
|
-
const cache = readBugCache(cacheDir, pid, fs);
|
|
685
|
-
if (!cache) { missing = true; continue; }
|
|
686
|
-
oldestSync = Math.min(oldestSync, cache.syncedAt);
|
|
687
|
-
for (const b of cache.bugs) merged.push({ ...b, productId: cache.product });
|
|
744
|
+
if (!knownIds.has(String(projectId))) {
|
|
745
|
+
throw new ZentaoError('INVALID_PARAMETER', `项目 ${projectId} 在当前服务器不存在。可用项目 ID: ${[...knownIds].slice(0, 20).join(', ') || '(无)'}`);
|
|
688
746
|
}
|
|
689
|
-
|
|
690
|
-
|
|
747
|
+
const data = await client.request('GET', `/api.php/v1/projects/${projectId}/builds`);
|
|
748
|
+
const builds = (data.builds || []).map(shapeBuild);
|
|
749
|
+
const limit = Number(parsed.flags.limit) || 20;
|
|
750
|
+
const offset = Number(parsed.flags.offset) || 0;
|
|
751
|
+
const page = paginate(builds, limit, offset);
|
|
752
|
+
const out = { total: page.total, count: page.count, offset: page.offset, has_more: page.has_more, next_offset: page.next_offset, builds: page.items };
|
|
753
|
+
if (parsed.flags.pretty) return { stdout: buildsToMarkdown({ ...out, builds: page.items }) };
|
|
754
|
+
return { stdout: JSON.stringify(out, null, 2) };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// 统一 bug 列表 → 只在本地【工作集】内查(先 sync 后查,list 不访问服务端)
|
|
758
|
+
// bug list / bug search 两者等价,search 为隐藏别名
|
|
759
|
+
async function cmdBugList(parsed, config, deps) {
|
|
760
|
+
const { cacheDir, fs, now } = deps;
|
|
761
|
+
const set = readWorkingSet(cacheDir, config, fs);
|
|
762
|
+
if (!set) {
|
|
763
|
+
return { stderr: '无本地工作集(或已切换服务器/账号),请先:zentao bug sync --product <ID|名称前缀>', exitCode: 2 };
|
|
691
764
|
}
|
|
765
|
+
const nowMs = typeof now === 'function' ? now() : (now ?? Date.now());
|
|
766
|
+
let out = set.bugs.map((b) => ({ ...b }));
|
|
692
767
|
|
|
693
|
-
// ---- 本地过滤
|
|
694
|
-
let out = merged;
|
|
768
|
+
// ---- 本地过滤 ----(--product 退化为本地 productId 过滤,不再访问服务端)
|
|
695
769
|
if (parsed.flags.mine) out = out.filter((b) => b.assignedTo === config.username);
|
|
770
|
+
if (parsed.flags.product) out = out.filter((b) => String(b.productId) === String(parsed.flags.product));
|
|
696
771
|
if (parsed.flags.title) {
|
|
697
772
|
const kw = String(parsed.flags.title).toLowerCase();
|
|
698
773
|
out = out.filter((b) => String(b.title || '').toLowerCase().includes(kw));
|
|
@@ -727,14 +802,14 @@ async function cmdBugList(parsed, config, deps) {
|
|
|
727
802
|
|
|
728
803
|
// staleness 提示
|
|
729
804
|
let stderr;
|
|
730
|
-
if (
|
|
731
|
-
const hrs = ((nowMs -
|
|
732
|
-
stderr = `⚠
|
|
805
|
+
if (nowMs - set.syncedAt > CACHE_STALE_MS) {
|
|
806
|
+
const hrs = ((nowMs - set.syncedAt) / 3600000).toFixed(1);
|
|
807
|
+
stderr = `⚠ 工作集可能过期(${hrs}h 前同步),关键判断请 'bug get <id>' 或重新 'bug sync'`;
|
|
733
808
|
}
|
|
734
809
|
if (parsed.flags.pretty) {
|
|
735
810
|
return { stdout: bugListToMarkdown({ total: out.length, count: page.length, bugs: page }), stderr };
|
|
736
811
|
}
|
|
737
|
-
const body = { syncedAt:
|
|
812
|
+
const body = { syncedAt: set.syncedAt, product: set.product, total: out.length, count: page.length, offset, has_more: end < out.length, bugs: page };
|
|
738
813
|
return { stdout: JSON.stringify(body, null, 2), stderr };
|
|
739
814
|
}
|
|
740
815
|
|
|
@@ -750,21 +825,39 @@ async function cmdBugGet(parsed, client) {
|
|
|
750
825
|
|
|
751
826
|
// 创建 Bug
|
|
752
827
|
// 依据 禅道APIv1.0.md §创建Bug:POST /products/id/bugs(brief 原写 /projects/id/bugs 为误,文档以产品为作用域)
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
if (!productId) throw new ZentaoError('INVALID_PARAMETER', '未指定 --project 且无 ZENTAO_DEFAULT_PROJECT');
|
|
828
|
+
async function cmdBugCreate(parsed, client, config) {
|
|
829
|
+
// 文档作用域为 product;保留 --project/--product 旗标与 defaultProject 配置,语义等同 productId
|
|
830
|
+
const defaultProject = config?.defaultProject;
|
|
831
|
+
let productId = parsed.flags.project || parsed.flags.product || defaultProject;
|
|
832
|
+
if (!productId) throw new ZentaoError('INVALID_PARAMETER', '未指定 --project/--product 且无 ZENTAO_DEFAULT_PROJECT');
|
|
758
833
|
if (!parsed.flags.title) return { stderr: '错误:缺少 --title', exitCode: 2 };
|
|
834
|
+
// 名称前缀解析 + 存在性校验(与 sync 一致,防 defaultProject 指向不存在产品时静默出错)
|
|
835
|
+
const productList = await client.request('GET', '/api.php/v1/products');
|
|
836
|
+
const products = productList.products || [];
|
|
837
|
+
const knownIds = new Set(products.map((p) => String(p.id)));
|
|
838
|
+
if (!/^\d+$/.test(String(productId))) {
|
|
839
|
+
const matches = products.filter((p) => p.name && String(p.name).startsWith(productId));
|
|
840
|
+
if (!matches.length) {
|
|
841
|
+
const names = products.slice(0, 20).map((p) => p.name).filter(Boolean).join(', ');
|
|
842
|
+
throw new ZentaoError('INVALID_PARAMETER', `未找到名前缀匹配 "${productId}" 的产品。已知产品: ${names || '(无)'}`);
|
|
843
|
+
}
|
|
844
|
+
productId = matches[0].id;
|
|
845
|
+
}
|
|
846
|
+
if (!knownIds.has(String(productId))) {
|
|
847
|
+
throw new ZentaoError('INVALID_PARAMETER', `产品 ${productId} 在当前服务器不存在。可用产品 ID: ${[...knownIds].slice(0, 20).join(', ') || '(无)'}`);
|
|
848
|
+
}
|
|
759
849
|
const body = {
|
|
760
850
|
title: parsed.flags.title,
|
|
761
|
-
//
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
851
|
+
// openedBuild(影响版本):多数 zentao 版本必填,默认 trunk(主线)可在无 build 记录时创建;--build 可覆盖
|
|
852
|
+
openedBuild: parsed.flags.build || 'trunk',
|
|
853
|
+
// type(Bug类型):文档标必填,默认 code;--type 可覆盖(codeerror/interface/config/security/...)
|
|
854
|
+
type: parsed.flags.type || 'code',
|
|
855
|
+
// 默认指派给当前用户(自己),--assigned-to 可覆盖
|
|
856
|
+
assignedTo: parsed.flags['assigned-to'] || config?.username || '',
|
|
857
|
+
// severity/pri 文档标必填,默认 3(次要/中);--severity/--priority 可覆盖
|
|
858
|
+
severity: parsed.flags.severity || '3',
|
|
859
|
+
pri: parsed.flags.priority || '3',
|
|
766
860
|
...(parsed.flags.steps ? { steps: parsed.flags.steps } : {}),
|
|
767
|
-
// openedBuild 在部分版本必填,留 TODO:实测后补默认值
|
|
768
861
|
};
|
|
769
862
|
const data = await client.request('POST', `/api.php/v1/products/${productId}/bugs`, { body });
|
|
770
863
|
return { stdout: JSON.stringify({ id: data.id ?? data.bugId ?? null }, null, 2) };
|
|
@@ -772,19 +865,20 @@ async function cmdBugCreate(parsed, client, defaultProject) {
|
|
|
772
865
|
|
|
773
866
|
// 解决 Bug
|
|
774
867
|
// 依据 禅道APIv1.0.md §解决Bug:POST /bugs/id/resolve,body resolution 必填
|
|
775
|
-
async function cmdBugResolve(parsed, client, deps) {
|
|
868
|
+
async function cmdBugResolve(parsed, client, deps, config) {
|
|
776
869
|
const bugId = parsed.positional[0];
|
|
777
870
|
if (!bugId) return { stderr: '错误:缺少 bugId', exitCode: 2 };
|
|
778
871
|
if (!parsed.flags.resolution) return { stderr: `错误:缺少 --resolution(可选:${RESOLUTIONS.join(',')})`, exitCode: 2 };
|
|
779
872
|
const body = {
|
|
780
873
|
resolution: parsed.flags.resolution,
|
|
781
|
-
|
|
874
|
+
// resolvedBuild(解决版本):21.x 起必填,默认 trunk(主线);--build 可覆盖
|
|
875
|
+
resolvedBuild: parsed.flags.build || 'trunk',
|
|
782
876
|
...(parsed.flags.comment ? { comment: parsed.flags.comment } : {}),
|
|
783
877
|
};
|
|
784
878
|
const data = await client.request('POST', `/api.php/v1/bugs/${bugId}/resolve`, { body });
|
|
785
|
-
// best-effort
|
|
786
|
-
if (deps?.cacheDir && deps?.fs) {
|
|
787
|
-
|
|
879
|
+
// best-effort 更新本地工作集(不阻断)
|
|
880
|
+
if (deps?.cacheDir && deps?.fs && config) {
|
|
881
|
+
updateWorkingSetBug(deps.cacheDir, config, bugId, { status: 'resolved', resolution: parsed.flags.resolution }, deps.fs);
|
|
788
882
|
}
|
|
789
883
|
return { stdout: JSON.stringify({ id: bugId, resolved: true, raw: data?.id ?? null }, null, 2) };
|
|
790
884
|
}
|
|
@@ -794,19 +888,37 @@ async function cmdBugResolve(parsed, client, deps) {
|
|
|
794
888
|
// 亦无独立 comment 端点)。指派复用「修改 Bug」通道,body 仅传 assignedTo 字段。
|
|
795
889
|
// 字段名 assignedTo 为禅道各动作体(confirm/active/task 等)通用指派字段,与文档一致。
|
|
796
890
|
// TODO 实测受限(只读环境)未做活体验证。
|
|
797
|
-
async function cmdBugAssign(parsed, client, deps) {
|
|
891
|
+
async function cmdBugAssign(parsed, client, deps, config) {
|
|
798
892
|
const bugId = parsed.positional[0];
|
|
799
893
|
if (!bugId) return { stderr: '错误:缺少 bugId', exitCode: 2 };
|
|
800
894
|
if (!parsed.flags.to) return { stderr: '错误:缺少 --to <user>', exitCode: 2 };
|
|
801
895
|
const body = { assignedTo: parsed.flags.to };
|
|
802
896
|
const data = await client.request('PUT', `/api.php/v1/bugs/${bugId}`, { body });
|
|
803
|
-
// best-effort
|
|
804
|
-
if (deps?.cacheDir && deps?.fs) {
|
|
805
|
-
|
|
897
|
+
// best-effort 更新本地工作集(不阻断)
|
|
898
|
+
if (deps?.cacheDir && deps?.fs && config) {
|
|
899
|
+
updateWorkingSetBug(deps.cacheDir, config, bugId, { assignedTo: parsed.flags.to }, deps.fs);
|
|
806
900
|
}
|
|
807
901
|
return { stdout: JSON.stringify({ id: bugId, assignedTo: parsed.flags.to, raw: data?.id ?? null }, null, 2) };
|
|
808
902
|
}
|
|
809
903
|
|
|
904
|
+
// 确认 Bug:依据 禅道APIv1.0.md §2.14.6 POST /bugs/{id}/confirm(body 字段全可选)
|
|
905
|
+
async function cmdBugConfirm(parsed, client, deps, config) {
|
|
906
|
+
const bugId = parsed.positional[0];
|
|
907
|
+
if (!bugId) return { stderr: '错误:缺少 bugId', exitCode: 2 };
|
|
908
|
+
const body = {
|
|
909
|
+
...(parsed.flags['assigned-to'] ? { assignedTo: parsed.flags['assigned-to'] } : {}),
|
|
910
|
+
...(parsed.flags.type ? { type: parsed.flags.type } : {}),
|
|
911
|
+
...(parsed.flags.priority ? { pri: parsed.flags.priority } : {}),
|
|
912
|
+
...(parsed.flags.comment ? { comment: parsed.flags.comment } : {}),
|
|
913
|
+
};
|
|
914
|
+
const data = await client.request('POST', `/api.php/v1/bugs/${bugId}/confirm`, { body });
|
|
915
|
+
// best-effort:若改了优先级,同步到本地工作集(confirmed 标志不在列表 shape 内,不更新)
|
|
916
|
+
if (deps?.cacheDir && deps?.fs && config && parsed.flags.priority) {
|
|
917
|
+
updateWorkingSetBug(deps.cacheDir, config, bugId, { priority: parsed.flags.priority }, deps.fs);
|
|
918
|
+
}
|
|
919
|
+
return { stdout: JSON.stringify({ id: bugId, confirmed: true, raw: data?.id ?? null }, null, 2) };
|
|
920
|
+
}
|
|
921
|
+
|
|
810
922
|
// ============ 本地 Bug 索引(sync / search) ============
|
|
811
923
|
|
|
812
924
|
// bug 列表服务端 browseType 合法值(源码 module/bug/model.php getBugs 398-414)
|
|
@@ -832,71 +944,62 @@ export const BUG_BROWSE_TYPES = [
|
|
|
832
944
|
// 缓存过期阈值(ms),默认 1h,可由 ZENTAO_CACHE_STALE_MS 覆盖
|
|
833
945
|
const CACHE_STALE_MS = Number(process.env.ZENTAO_CACHE_STALE_MS) || 3600 * 1000;
|
|
834
946
|
|
|
835
|
-
//
|
|
836
|
-
|
|
837
|
-
|
|
947
|
+
// ============ 本地工作集(单文件,绑 url+account) ============
|
|
948
|
+
// 设计:sync 定义工作集(整体替换),list 只在工作集内查,不再跨产品/跨服务器合并。
|
|
949
|
+
// 工作集文件盖 url/account 戳:切服务器/切账号后,旧工作集 url/account 不匹配 → 视为未 sync,
|
|
950
|
+
// 天然隔离,无需主动清理。
|
|
951
|
+
|
|
952
|
+
// 工作集文件名(整个本地只有一个工作集)
|
|
953
|
+
export function workingSetPath(dir) {
|
|
954
|
+
return `${dir}/working-set.json`;
|
|
838
955
|
}
|
|
839
956
|
|
|
840
|
-
|
|
957
|
+
// 身份戳:工作集归属哪个服务器+账号
|
|
958
|
+
function stampOf(config) {
|
|
959
|
+
return { url: config.zentaoUrl, account: config.username };
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// 读工作集;url/account 与当前配置不匹配 → 返回 null(视为未 sync)。注入 fs 便于测试。
|
|
963
|
+
export function readWorkingSet(dir, config, fs) {
|
|
841
964
|
try {
|
|
842
|
-
const raw = fs.readFileSync(
|
|
965
|
+
const raw = fs.readFileSync(workingSetPath(dir), 'utf8');
|
|
843
966
|
const obj = JSON.parse(raw);
|
|
844
|
-
if (obj
|
|
845
|
-
return null; //
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
return null; //
|
|
967
|
+
if (!obj || !Array.isArray(obj.bugs)) return null; // 结构不符当作无工作集
|
|
968
|
+
if (obj.url !== config.zentaoUrl || obj.account !== config.username) return null; // 跨服务器/账号 → 不可见
|
|
969
|
+
return obj;
|
|
970
|
+
} catch {
|
|
971
|
+
return null; // ENOENT / 损坏都当无工作集
|
|
849
972
|
}
|
|
850
973
|
}
|
|
851
974
|
|
|
852
|
-
//
|
|
853
|
-
export function
|
|
854
|
-
const final =
|
|
975
|
+
// 原子写:盖身份戳 + tmp→rename,避免崩溃损坏;目录自动建;权限收紧
|
|
976
|
+
export function writeWorkingSet(dir, config, data, fs) {
|
|
977
|
+
const final = workingSetPath(dir);
|
|
855
978
|
const tmp = `${final}.tmp`;
|
|
979
|
+
const stamped = { ...data, ...stampOf(config) }; // 戳最后写,始终反映当前身份
|
|
856
980
|
try { fs.mkdirSync(dir, { recursive: true }); } catch { /* 已存在或不可建 */ }
|
|
857
|
-
fs.writeFileSync(tmp, JSON.stringify(
|
|
981
|
+
fs.writeFileSync(tmp, JSON.stringify(stamped), { mode: 0o600 });
|
|
858
982
|
try { if (typeof fs.chmodSync === 'function') fs.chmodSync(tmp, 0o600); } catch { /* Windows no-op */ }
|
|
859
983
|
fs.renameSync(tmp, final);
|
|
860
984
|
}
|
|
861
985
|
|
|
862
|
-
//
|
|
863
|
-
export function
|
|
864
|
-
let entries = [];
|
|
865
|
-
try { entries = fs.readdirSync(cacheDir); } catch { return []; }
|
|
866
|
-
const out = [];
|
|
867
|
-
for (const name of entries) {
|
|
868
|
-
const m = name.match(/^bugs-(.+)\.json$/);
|
|
869
|
-
if (m) out.push(m[1]);
|
|
870
|
-
}
|
|
871
|
-
return out;
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
// best-effort 更新某产品缓存中指定 bug;命中并写入返回 true,否则 false;失败静默
|
|
875
|
-
export function updateCachedBug(cacheDir, productId, bugId, patch, fs) {
|
|
986
|
+
// best-effort:在工作集内就地更新某 bug(写命令 resolve/assign 后),命中并写入返回 true;失败静默
|
|
987
|
+
export function updateWorkingSetBug(cacheDir, config, bugId, patch, fs) {
|
|
876
988
|
try {
|
|
877
|
-
const
|
|
878
|
-
if (!
|
|
989
|
+
const set = readWorkingSet(cacheDir, config, fs);
|
|
990
|
+
if (!set) return false;
|
|
879
991
|
let hit = false;
|
|
880
|
-
|
|
992
|
+
set.bugs = set.bugs.map((b) => {
|
|
881
993
|
if (String(b.id) === String(bugId)) { hit = true; return { ...b, ...patch }; }
|
|
882
994
|
return b;
|
|
883
995
|
});
|
|
884
|
-
if (hit)
|
|
996
|
+
if (hit) writeWorkingSet(cacheDir, config, set, fs);
|
|
885
997
|
return hit;
|
|
886
998
|
} catch {
|
|
887
999
|
return false;
|
|
888
1000
|
}
|
|
889
1001
|
}
|
|
890
1002
|
|
|
891
|
-
// 扫描所有已缓存产品,更新命中 bug(写命令后 best-effort)
|
|
892
|
-
export function updateCachedBugAnywhere(cacheDir, bugId, patch, fs) {
|
|
893
|
-
let hit = false;
|
|
894
|
-
for (const pid of listCacheProducts(fs, cacheDir)) {
|
|
895
|
-
if (updateCachedBug(cacheDir, pid, bugId, patch, fs)) hit = true;
|
|
896
|
-
}
|
|
897
|
-
return hit;
|
|
898
|
-
}
|
|
899
|
-
|
|
900
1003
|
// 输出 browseType 合法值
|
|
901
1004
|
function cmdBugBrowseTypes(parsed) {
|
|
902
1005
|
const rows = BUG_BROWSE_TYPES;
|
|
@@ -908,20 +1011,28 @@ function cmdBugBrowseTypes(parsed) {
|
|
|
908
1011
|
return { stdout: JSON.stringify({ browseTypes: rows.map((r) => r.value) }, null, 2) };
|
|
909
1012
|
}
|
|
910
1013
|
|
|
911
|
-
// 一次性同步某产品 bug
|
|
1014
|
+
// 一次性同步某产品 bug 到本地【工作集】(整体替换,不再按产品分文件累加)
|
|
912
1015
|
async function cmdBugSync(parsed, client, config, { cacheDir, fs }) {
|
|
913
1016
|
let productId = parsed.flags.product || config.defaultProject;
|
|
1017
|
+
// 拉一次产品列表:名称前缀解析 + 存在性校验共用,避免重复请求
|
|
1018
|
+
const productList = await client.request('GET', '/api.php/v1/products');
|
|
1019
|
+
const products = productList.products || [];
|
|
1020
|
+
const knownIds = new Set(products.map((p) => String(p.id)));
|
|
914
1021
|
// --product 非数字 → 产品名前缀匹配
|
|
915
1022
|
if (productId && !/^\d+$/.test(String(productId))) {
|
|
916
|
-
const
|
|
917
|
-
const matches = (data.products || []).filter((p) => p.name && String(p.name).startsWith(productId));
|
|
1023
|
+
const matches = products.filter((p) => p.name && String(p.name).startsWith(productId));
|
|
918
1024
|
if (!matches.length) {
|
|
919
|
-
const names =
|
|
1025
|
+
const names = products.slice(0, 20).map((p) => p.name).filter(Boolean).join(', ');
|
|
920
1026
|
throw new ZentaoError('INVALID_PARAMETER', `未找到名前缀匹配 "${productId}" 的产品。已知产品: ${names || '(无)'}`);
|
|
921
1027
|
}
|
|
922
1028
|
productId = matches[0].id;
|
|
923
1029
|
}
|
|
924
1030
|
if (!productId) throw new ZentaoError('INVALID_PARAMETER', '未指定 --product 且无 ZENTAO_DEFAULT_PROJECT');
|
|
1031
|
+
// 存在性校验:禅道对不存在的 product id 会【静默回退】到别的产品吐数据,
|
|
1032
|
+
// 不校验就会把 A 产品的请求错当成 B 产品并缓存(曾导致切本地后仍显示老数据)。
|
|
1033
|
+
if (!knownIds.has(String(productId))) {
|
|
1034
|
+
throw new ZentaoError('INVALID_PARAMETER', `产品 ${productId} 在当前服务器不存在。可用产品 ID: ${[...knownIds].slice(0, 20).join(', ') || '(无)'}`);
|
|
1035
|
+
}
|
|
925
1036
|
const browseType = parsed.flags.status || 'unclosed'; // sync 的 --status = 服务端 browseType,默认 unclosed
|
|
926
1037
|
const limit = 100;
|
|
927
1038
|
const all = [];
|
|
@@ -932,10 +1043,11 @@ async function cmdBugSync(parsed, client, config, { cacheDir, fs }) {
|
|
|
932
1043
|
if (!data.bugs || data.bugs.length < limit) break; // 不足一页 = 取完
|
|
933
1044
|
}
|
|
934
1045
|
// --mine:本地按 assignedTo === 当前账号 过滤(可与任意 browseType 叠加)
|
|
935
|
-
const
|
|
936
|
-
const
|
|
937
|
-
|
|
938
|
-
|
|
1046
|
+
const mine = !!parsed.flags.mine;
|
|
1047
|
+
const final = mine ? all.filter((b) => b.assignedTo === config.username) : all;
|
|
1048
|
+
const set = { product: String(productId), status: browseType, mine, syncedAt: Date.now(), count: final.length, bugs: final };
|
|
1049
|
+
writeWorkingSet(cacheDir, config, set, fs); // 整体替换工作集(盖 url/account 戳);仅成功后写,失败不破坏旧工作集
|
|
1050
|
+
return { stdout: JSON.stringify({ product: String(productId), status: browseType, mine, syncedAt: set.syncedAt, count: final.length }, null, 2) };
|
|
939
1051
|
}
|
|
940
1052
|
|
|
941
1053
|
// 命令分发:根据 parsed 派发到对应 cmd,捕获 ZentaoError 转 exitCode
|
|
@@ -951,11 +1063,13 @@ export async function runCommand(parsed, { config, deps }) {
|
|
|
951
1063
|
else if (parsed.command === 'init') res = await cmdInit(parsed, config, deps);
|
|
952
1064
|
else if (parsed.command === 'project' && parsed.sub === 'list') res = await cmdProjectList(parsed, client);
|
|
953
1065
|
else if (parsed.command === 'product' && parsed.sub === 'list') res = await cmdProductList(parsed, client);
|
|
1066
|
+
else if (parsed.command === 'build' && parsed.sub === 'list') res = await cmdBuildList(parsed, client, config);
|
|
954
1067
|
else if (parsed.command === 'bug' && (parsed.sub === 'list' || parsed.sub === 'search')) res = await cmdBugList(parsed, config, deps);
|
|
955
1068
|
else if (parsed.command === 'bug' && parsed.sub === 'get') res = await cmdBugGet(parsed, client);
|
|
956
|
-
else if (parsed.command === 'bug' && parsed.sub === 'create') res = await cmdBugCreate(parsed, client, config
|
|
957
|
-
else if (parsed.command === 'bug' && parsed.sub === 'resolve') res = await cmdBugResolve(parsed, client, deps);
|
|
958
|
-
else if (parsed.command === 'bug' && parsed.sub === 'assign') res = await cmdBugAssign(parsed, client, deps);
|
|
1069
|
+
else if (parsed.command === 'bug' && parsed.sub === 'create') res = await cmdBugCreate(parsed, client, config);
|
|
1070
|
+
else if (parsed.command === 'bug' && parsed.sub === 'resolve') res = await cmdBugResolve(parsed, client, deps, config);
|
|
1071
|
+
else if (parsed.command === 'bug' && parsed.sub === 'assign') res = await cmdBugAssign(parsed, client, deps, config);
|
|
1072
|
+
else if (parsed.command === 'bug' && parsed.sub === 'confirm') res = await cmdBugConfirm(parsed, client, deps, config);
|
|
959
1073
|
else if (parsed.command === 'bug' && parsed.sub === 'sync') res = await cmdBugSync(parsed, client, config, deps);
|
|
960
1074
|
else if (parsed.command === 'bug' && parsed.sub === 'browse-types') res = cmdBugBrowseTypes(parsed);
|
|
961
1075
|
else if (parsed.command === 'ping') res = await cmdPing(parsed, client, config);
|