@lyra-ai/toolkit 0.2.3 → 0.2.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/package.json +5 -5
- package/src/zentao.mjs +195 -128
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lyra-ai/toolkit",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "面向 AI agent 的零依赖开发工具集",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"toolkit": "
|
|
8
|
-
"zentao": "
|
|
9
|
-
"log-query": "
|
|
10
|
-
"skillhub": "
|
|
7
|
+
"toolkit": "bin/toolkit.mjs",
|
|
8
|
+
"zentao": "bin/zentao.mjs",
|
|
9
|
+
"log-query": "bin/log-query.mjs",
|
|
10
|
+
"skillhub": "bin/skillhub.mjs"
|
|
11
11
|
},
|
|
12
12
|
"engines": {
|
|
13
13
|
"node": ">=18"
|
package/src/zentao.mjs
CHANGED
|
@@ -46,19 +46,21 @@ 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 才显原文)
|
|
@@ -307,6 +309,16 @@ export function shapeProduct(p) {
|
|
|
307
309
|
};
|
|
308
310
|
}
|
|
309
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
|
+
|
|
310
322
|
export function shapeBugListItem(b) {
|
|
311
323
|
return {
|
|
312
324
|
id: b.id, title: b.title, severity: b.severity, priority: b.pri,
|
|
@@ -345,6 +357,15 @@ export function productsToMarkdown({ total, count, products }) {
|
|
|
345
357
|
return lines.join('\n');
|
|
346
358
|
}
|
|
347
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
|
+
|
|
348
369
|
export function bugListToMarkdown({ total, count, bugs }) {
|
|
349
370
|
const lines = ['# Bug List', '', `Found ${total} bugs (showing ${count})`, '',
|
|
350
371
|
'| ID | Title | Sev | Pri | Status | AssignedTo | OpenedBy | OpenedDate |', '|---|---|---|---|---|---|---|---|'];
|
|
@@ -574,24 +595,29 @@ async function maskedPrompt(question) {
|
|
|
574
595
|
return new Promise((resolve) => {
|
|
575
596
|
process.stdout.write(question);
|
|
576
597
|
let buf = '';
|
|
598
|
+
// Windows 终端回车是 \r\n:上一个 rl.question 按 \r 返回后,残留 \n 会被下一个 raw read 读到。
|
|
599
|
+
// 用 started 标志忽略"开头还没输入真字符时"收到的 \r/\n,避免空密码直接提交(向导秒退)。
|
|
600
|
+
let started = false;
|
|
601
|
+
const finish = () => {
|
|
602
|
+
process.stdout.write('\n');
|
|
603
|
+
process.stdin.removeListener('data', onData);
|
|
604
|
+
process.stdin.setRawMode(false);
|
|
605
|
+
resolve(buf);
|
|
606
|
+
};
|
|
577
607
|
const onData = (c) => {
|
|
578
608
|
const ch = c.toString();
|
|
579
|
-
if (ch === '\
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
process.
|
|
583
|
-
|
|
584
|
-
} else if (ch === '') {
|
|
585
|
-
process.stdout.write('\n');
|
|
586
|
-
process.exit(0);
|
|
587
|
-
} else if (ch === '\x7f' || ch === '\b') {
|
|
588
|
-
if (buf.length > 0) { buf = buf.slice(0, -1); process.stdout.write('\b \b'); }
|
|
589
|
-
} else {
|
|
590
|
-
buf += ch;
|
|
591
|
-
process.stdout.write('*');
|
|
609
|
+
if (ch === '\x03') { process.stdout.write('\n'); process.exit(0); } // Ctrl+C
|
|
610
|
+
if (ch === '\r' || ch === '\n') { if (started) finish(); return; } // 开头换行 = 残留,忽略
|
|
611
|
+
if (ch === '\x7f' || ch === '\b') {
|
|
612
|
+
if (buf.length > 0) { buf = buf.slice(0, -1); process.stdout.write('\b \b'); started = true; }
|
|
613
|
+
return;
|
|
592
614
|
}
|
|
615
|
+
buf += ch;
|
|
616
|
+
started = true;
|
|
617
|
+
process.stdout.write('*');
|
|
593
618
|
};
|
|
594
619
|
process.stdin.setRawMode(true);
|
|
620
|
+
process.stdin.resume();
|
|
595
621
|
process.stdin.on('data', onData);
|
|
596
622
|
});
|
|
597
623
|
}
|
|
@@ -640,14 +666,10 @@ async function cmdInit(parsed, config, deps) {
|
|
|
640
666
|
|
|
641
667
|
const u = await ask('禅道地址 (ZENTAO_URL)', config.zentaoUrl || '');
|
|
642
668
|
const un = await ask('用户名 (ZENTAO_USERNAME)', config.username || '');
|
|
643
|
-
const pw = await (async () => {
|
|
644
|
-
rl.pause();
|
|
645
|
-
const p = await maskedPrompt('密码 (ZENTAO_PASSWORD): ');
|
|
646
|
-
rl.resume();
|
|
647
|
-
return p;
|
|
648
|
-
})();
|
|
649
669
|
|
|
670
|
+
// 先关掉 readline,彻底交出 stdin 控制权,再用裸 raw-mode 收密码(避免与 readline 冲突)
|
|
650
671
|
rl.close();
|
|
672
|
+
const pw = await maskedPrompt('密码 (ZENTAO_PASSWORD): ');
|
|
651
673
|
|
|
652
674
|
setConfigKey('ZENTAO_URL', u, deps.fs);
|
|
653
675
|
setConfigKey('ZENTAO_USERNAME', un, deps.fs);
|
|
@@ -704,43 +726,49 @@ async function cmdProductList(parsed, client) {
|
|
|
704
726
|
return { stdout: JSON.stringify(out, null, 2) };
|
|
705
727
|
}
|
|
706
728
|
|
|
707
|
-
//
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
//
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
const matches =
|
|
729
|
+
// 列出项目版本(builds):依据 禅道APIv1.0.md §2.11.1 GET /projects/{id}/builds
|
|
730
|
+
async function cmdBuildList(parsed, client, config) {
|
|
731
|
+
let projectId = parsed.flags.project || config.defaultProject;
|
|
732
|
+
if (!projectId) throw new ZentaoError('INVALID_PARAMETER', '未指定 --project 且无 ZENTAO_DEFAULT_PROJECT');
|
|
733
|
+
// 名称前缀解析 + 存在性校验(与 sync/create 一致)
|
|
734
|
+
const projectList = await client.request('GET', '/api.php/v1/projects');
|
|
735
|
+
const projects = projectList.projects || [];
|
|
736
|
+
const knownIds = new Set(projects.map((p) => String(p.id)));
|
|
737
|
+
if (!/^\d+$/.test(String(projectId))) {
|
|
738
|
+
const matches = projects.filter((p) => p.name && String(p.name).startsWith(projectId));
|
|
717
739
|
if (!matches.length) {
|
|
718
|
-
const names =
|
|
719
|
-
|
|
740
|
+
const names = projects.slice(0, 20).map((p) => p.name).filter(Boolean).join(', ');
|
|
741
|
+
throw new ZentaoError('INVALID_PARAMETER', `未找到名前缀匹配 "${projectId}" 的项目。已知项目: ${names || '(无)'}`);
|
|
720
742
|
}
|
|
721
|
-
|
|
743
|
+
projectId = matches[0].id;
|
|
722
744
|
}
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
return { stderr: '无本地缓存,请先:zentao bug sync --product <ID>', exitCode: 2 };
|
|
745
|
+
if (!knownIds.has(String(projectId))) {
|
|
746
|
+
throw new ZentaoError('INVALID_PARAMETER', `项目 ${projectId} 在当前服务器不存在。可用项目 ID: ${[...knownIds].slice(0, 20).join(', ') || '(无)'}`);
|
|
726
747
|
}
|
|
727
|
-
const
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
748
|
+
const data = await client.request('GET', `/api.php/v1/projects/${projectId}/builds`);
|
|
749
|
+
const builds = (data.builds || []).map(shapeBuild);
|
|
750
|
+
const limit = Number(parsed.flags.limit) || 20;
|
|
751
|
+
const offset = Number(parsed.flags.offset) || 0;
|
|
752
|
+
const page = paginate(builds, limit, offset);
|
|
753
|
+
const out = { total: page.total, count: page.count, offset: page.offset, has_more: page.has_more, next_offset: page.next_offset, builds: page.items };
|
|
754
|
+
if (parsed.flags.pretty) return { stdout: buildsToMarkdown({ ...out, builds: page.items }) };
|
|
755
|
+
return { stdout: JSON.stringify(out, null, 2) };
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// 统一 bug 列表 → 只在本地【工作集】内查(先 sync 后查,list 不访问服务端)
|
|
759
|
+
// bug list / bug search 两者等价,search 为隐藏别名
|
|
760
|
+
async function cmdBugList(parsed, config, deps) {
|
|
761
|
+
const { cacheDir, fs, now } = deps;
|
|
762
|
+
const set = readWorkingSet(cacheDir, config, fs);
|
|
763
|
+
if (!set) {
|
|
764
|
+
return { stderr: '无本地工作集(或已切换服务器/账号),请先:zentao bug sync --product <ID|名称前缀>', exitCode: 2 };
|
|
739
765
|
}
|
|
766
|
+
const nowMs = typeof now === 'function' ? now() : (now ?? Date.now());
|
|
767
|
+
let out = set.bugs.map((b) => ({ ...b }));
|
|
740
768
|
|
|
741
|
-
// ---- 本地过滤
|
|
742
|
-
let out = merged;
|
|
769
|
+
// ---- 本地过滤 ----(--product 退化为本地 productId 过滤,不再访问服务端)
|
|
743
770
|
if (parsed.flags.mine) out = out.filter((b) => b.assignedTo === config.username);
|
|
771
|
+
if (parsed.flags.product) out = out.filter((b) => String(b.productId) === String(parsed.flags.product));
|
|
744
772
|
if (parsed.flags.title) {
|
|
745
773
|
const kw = String(parsed.flags.title).toLowerCase();
|
|
746
774
|
out = out.filter((b) => String(b.title || '').toLowerCase().includes(kw));
|
|
@@ -775,14 +803,14 @@ async function cmdBugList(parsed, config, deps) {
|
|
|
775
803
|
|
|
776
804
|
// staleness 提示
|
|
777
805
|
let stderr;
|
|
778
|
-
if (
|
|
779
|
-
const hrs = ((nowMs -
|
|
780
|
-
stderr = `⚠
|
|
806
|
+
if (nowMs - set.syncedAt > CACHE_STALE_MS) {
|
|
807
|
+
const hrs = ((nowMs - set.syncedAt) / 3600000).toFixed(1);
|
|
808
|
+
stderr = `⚠ 工作集可能过期(${hrs}h 前同步),关键判断请 'bug get <id>' 或重新 'bug sync'`;
|
|
781
809
|
}
|
|
782
810
|
if (parsed.flags.pretty) {
|
|
783
811
|
return { stdout: bugListToMarkdown({ total: out.length, count: page.length, bugs: page }), stderr };
|
|
784
812
|
}
|
|
785
|
-
const body = { syncedAt:
|
|
813
|
+
const body = { syncedAt: set.syncedAt, product: set.product, total: out.length, count: page.length, offset, has_more: end < out.length, bugs: page };
|
|
786
814
|
return { stdout: JSON.stringify(body, null, 2), stderr };
|
|
787
815
|
}
|
|
788
816
|
|
|
@@ -798,21 +826,39 @@ async function cmdBugGet(parsed, client) {
|
|
|
798
826
|
|
|
799
827
|
// 创建 Bug
|
|
800
828
|
// 依据 禅道APIv1.0.md §创建Bug:POST /products/id/bugs(brief 原写 /projects/id/bugs 为误,文档以产品为作用域)
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
if (!productId) throw new ZentaoError('INVALID_PARAMETER', '未指定 --project 且无 ZENTAO_DEFAULT_PROJECT');
|
|
829
|
+
async function cmdBugCreate(parsed, client, config) {
|
|
830
|
+
// 文档作用域为 product;保留 --project/--product 旗标与 defaultProject 配置,语义等同 productId
|
|
831
|
+
const defaultProject = config?.defaultProject;
|
|
832
|
+
let productId = parsed.flags.project || parsed.flags.product || defaultProject;
|
|
833
|
+
if (!productId) throw new ZentaoError('INVALID_PARAMETER', '未指定 --project/--product 且无 ZENTAO_DEFAULT_PROJECT');
|
|
806
834
|
if (!parsed.flags.title) return { stderr: '错误:缺少 --title', exitCode: 2 };
|
|
835
|
+
// 名称前缀解析 + 存在性校验(与 sync 一致,防 defaultProject 指向不存在产品时静默出错)
|
|
836
|
+
const productList = await client.request('GET', '/api.php/v1/products');
|
|
837
|
+
const products = productList.products || [];
|
|
838
|
+
const knownIds = new Set(products.map((p) => String(p.id)));
|
|
839
|
+
if (!/^\d+$/.test(String(productId))) {
|
|
840
|
+
const matches = products.filter((p) => p.name && String(p.name).startsWith(productId));
|
|
841
|
+
if (!matches.length) {
|
|
842
|
+
const names = products.slice(0, 20).map((p) => p.name).filter(Boolean).join(', ');
|
|
843
|
+
throw new ZentaoError('INVALID_PARAMETER', `未找到名前缀匹配 "${productId}" 的产品。已知产品: ${names || '(无)'}`);
|
|
844
|
+
}
|
|
845
|
+
productId = matches[0].id;
|
|
846
|
+
}
|
|
847
|
+
if (!knownIds.has(String(productId))) {
|
|
848
|
+
throw new ZentaoError('INVALID_PARAMETER', `产品 ${productId} 在当前服务器不存在。可用产品 ID: ${[...knownIds].slice(0, 20).join(', ') || '(无)'}`);
|
|
849
|
+
}
|
|
807
850
|
const body = {
|
|
808
851
|
title: parsed.flags.title,
|
|
809
|
-
//
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
852
|
+
// openedBuild(影响版本):多数 zentao 版本必填,默认 trunk(主线)可在无 build 记录时创建;--build 可覆盖
|
|
853
|
+
openedBuild: parsed.flags.build || 'trunk',
|
|
854
|
+
// type(Bug类型):文档标必填,默认 code;--type 可覆盖(codeerror/interface/config/security/...)
|
|
855
|
+
type: parsed.flags.type || 'code',
|
|
856
|
+
// 默认指派给当前用户(自己),--assigned-to 可覆盖
|
|
857
|
+
assignedTo: parsed.flags['assigned-to'] || config?.username || '',
|
|
858
|
+
// severity/pri 文档标必填,默认 3(次要/中);--severity/--priority 可覆盖
|
|
859
|
+
severity: parsed.flags.severity || '3',
|
|
860
|
+
pri: parsed.flags.priority || '3',
|
|
814
861
|
...(parsed.flags.steps ? { steps: parsed.flags.steps } : {}),
|
|
815
|
-
// openedBuild 在部分版本必填,留 TODO:实测后补默认值
|
|
816
862
|
};
|
|
817
863
|
const data = await client.request('POST', `/api.php/v1/products/${productId}/bugs`, { body });
|
|
818
864
|
return { stdout: JSON.stringify({ id: data.id ?? data.bugId ?? null }, null, 2) };
|
|
@@ -820,19 +866,20 @@ async function cmdBugCreate(parsed, client, defaultProject) {
|
|
|
820
866
|
|
|
821
867
|
// 解决 Bug
|
|
822
868
|
// 依据 禅道APIv1.0.md §解决Bug:POST /bugs/id/resolve,body resolution 必填
|
|
823
|
-
async function cmdBugResolve(parsed, client, deps) {
|
|
869
|
+
async function cmdBugResolve(parsed, client, deps, config) {
|
|
824
870
|
const bugId = parsed.positional[0];
|
|
825
871
|
if (!bugId) return { stderr: '错误:缺少 bugId', exitCode: 2 };
|
|
826
872
|
if (!parsed.flags.resolution) return { stderr: `错误:缺少 --resolution(可选:${RESOLUTIONS.join(',')})`, exitCode: 2 };
|
|
827
873
|
const body = {
|
|
828
874
|
resolution: parsed.flags.resolution,
|
|
829
|
-
|
|
875
|
+
// resolvedBuild(解决版本):21.x 起必填,默认 trunk(主线);--build 可覆盖
|
|
876
|
+
resolvedBuild: parsed.flags.build || 'trunk',
|
|
830
877
|
...(parsed.flags.comment ? { comment: parsed.flags.comment } : {}),
|
|
831
878
|
};
|
|
832
879
|
const data = await client.request('POST', `/api.php/v1/bugs/${bugId}/resolve`, { body });
|
|
833
|
-
// best-effort
|
|
834
|
-
if (deps?.cacheDir && deps?.fs) {
|
|
835
|
-
|
|
880
|
+
// best-effort 更新本地工作集(不阻断)
|
|
881
|
+
if (deps?.cacheDir && deps?.fs && config) {
|
|
882
|
+
updateWorkingSetBug(deps.cacheDir, config, bugId, { status: 'resolved', resolution: parsed.flags.resolution }, deps.fs);
|
|
836
883
|
}
|
|
837
884
|
return { stdout: JSON.stringify({ id: bugId, resolved: true, raw: data?.id ?? null }, null, 2) };
|
|
838
885
|
}
|
|
@@ -842,19 +889,37 @@ async function cmdBugResolve(parsed, client, deps) {
|
|
|
842
889
|
// 亦无独立 comment 端点)。指派复用「修改 Bug」通道,body 仅传 assignedTo 字段。
|
|
843
890
|
// 字段名 assignedTo 为禅道各动作体(confirm/active/task 等)通用指派字段,与文档一致。
|
|
844
891
|
// TODO 实测受限(只读环境)未做活体验证。
|
|
845
|
-
async function cmdBugAssign(parsed, client, deps) {
|
|
892
|
+
async function cmdBugAssign(parsed, client, deps, config) {
|
|
846
893
|
const bugId = parsed.positional[0];
|
|
847
894
|
if (!bugId) return { stderr: '错误:缺少 bugId', exitCode: 2 };
|
|
848
895
|
if (!parsed.flags.to) return { stderr: '错误:缺少 --to <user>', exitCode: 2 };
|
|
849
896
|
const body = { assignedTo: parsed.flags.to };
|
|
850
897
|
const data = await client.request('PUT', `/api.php/v1/bugs/${bugId}`, { body });
|
|
851
|
-
// best-effort
|
|
852
|
-
if (deps?.cacheDir && deps?.fs) {
|
|
853
|
-
|
|
898
|
+
// best-effort 更新本地工作集(不阻断)
|
|
899
|
+
if (deps?.cacheDir && deps?.fs && config) {
|
|
900
|
+
updateWorkingSetBug(deps.cacheDir, config, bugId, { assignedTo: parsed.flags.to }, deps.fs);
|
|
854
901
|
}
|
|
855
902
|
return { stdout: JSON.stringify({ id: bugId, assignedTo: parsed.flags.to, raw: data?.id ?? null }, null, 2) };
|
|
856
903
|
}
|
|
857
904
|
|
|
905
|
+
// 确认 Bug:依据 禅道APIv1.0.md §2.14.6 POST /bugs/{id}/confirm(body 字段全可选)
|
|
906
|
+
async function cmdBugConfirm(parsed, client, deps, config) {
|
|
907
|
+
const bugId = parsed.positional[0];
|
|
908
|
+
if (!bugId) return { stderr: '错误:缺少 bugId', exitCode: 2 };
|
|
909
|
+
const body = {
|
|
910
|
+
...(parsed.flags['assigned-to'] ? { assignedTo: parsed.flags['assigned-to'] } : {}),
|
|
911
|
+
...(parsed.flags.type ? { type: parsed.flags.type } : {}),
|
|
912
|
+
...(parsed.flags.priority ? { pri: parsed.flags.priority } : {}),
|
|
913
|
+
...(parsed.flags.comment ? { comment: parsed.flags.comment } : {}),
|
|
914
|
+
};
|
|
915
|
+
const data = await client.request('POST', `/api.php/v1/bugs/${bugId}/confirm`, { body });
|
|
916
|
+
// best-effort:若改了优先级,同步到本地工作集(confirmed 标志不在列表 shape 内,不更新)
|
|
917
|
+
if (deps?.cacheDir && deps?.fs && config && parsed.flags.priority) {
|
|
918
|
+
updateWorkingSetBug(deps.cacheDir, config, bugId, { priority: parsed.flags.priority }, deps.fs);
|
|
919
|
+
}
|
|
920
|
+
return { stdout: JSON.stringify({ id: bugId, confirmed: true, raw: data?.id ?? null }, null, 2) };
|
|
921
|
+
}
|
|
922
|
+
|
|
858
923
|
// ============ 本地 Bug 索引(sync / search) ============
|
|
859
924
|
|
|
860
925
|
// bug 列表服务端 browseType 合法值(源码 module/bug/model.php getBugs 398-414)
|
|
@@ -880,71 +945,62 @@ export const BUG_BROWSE_TYPES = [
|
|
|
880
945
|
// 缓存过期阈值(ms),默认 1h,可由 ZENTAO_CACHE_STALE_MS 覆盖
|
|
881
946
|
const CACHE_STALE_MS = Number(process.env.ZENTAO_CACHE_STALE_MS) || 3600 * 1000;
|
|
882
947
|
|
|
883
|
-
//
|
|
884
|
-
|
|
885
|
-
|
|
948
|
+
// ============ 本地工作集(单文件,绑 url+account) ============
|
|
949
|
+
// 设计:sync 定义工作集(整体替换),list 只在工作集内查,不再跨产品/跨服务器合并。
|
|
950
|
+
// 工作集文件盖 url/account 戳:切服务器/切账号后,旧工作集 url/account 不匹配 → 视为未 sync,
|
|
951
|
+
// 天然隔离,无需主动清理。
|
|
952
|
+
|
|
953
|
+
// 工作集文件名(整个本地只有一个工作集)
|
|
954
|
+
export function workingSetPath(dir) {
|
|
955
|
+
return `${dir}/working-set.json`;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// 身份戳:工作集归属哪个服务器+账号
|
|
959
|
+
function stampOf(config) {
|
|
960
|
+
return { url: config.zentaoUrl, account: config.username };
|
|
886
961
|
}
|
|
887
962
|
|
|
888
|
-
|
|
963
|
+
// 读工作集;url/account 与当前配置不匹配 → 返回 null(视为未 sync)。注入 fs 便于测试。
|
|
964
|
+
export function readWorkingSet(dir, config, fs) {
|
|
889
965
|
try {
|
|
890
|
-
const raw = fs.readFileSync(
|
|
966
|
+
const raw = fs.readFileSync(workingSetPath(dir), 'utf8');
|
|
891
967
|
const obj = JSON.parse(raw);
|
|
892
|
-
if (obj
|
|
893
|
-
return null; //
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
return null; //
|
|
968
|
+
if (!obj || !Array.isArray(obj.bugs)) return null; // 结构不符当作无工作集
|
|
969
|
+
if (obj.url !== config.zentaoUrl || obj.account !== config.username) return null; // 跨服务器/账号 → 不可见
|
|
970
|
+
return obj;
|
|
971
|
+
} catch {
|
|
972
|
+
return null; // ENOENT / 损坏都当无工作集
|
|
897
973
|
}
|
|
898
974
|
}
|
|
899
975
|
|
|
900
|
-
//
|
|
901
|
-
export function
|
|
902
|
-
const final =
|
|
976
|
+
// 原子写:盖身份戳 + tmp→rename,避免崩溃损坏;目录自动建;权限收紧
|
|
977
|
+
export function writeWorkingSet(dir, config, data, fs) {
|
|
978
|
+
const final = workingSetPath(dir);
|
|
903
979
|
const tmp = `${final}.tmp`;
|
|
980
|
+
const stamped = { ...data, ...stampOf(config) }; // 戳最后写,始终反映当前身份
|
|
904
981
|
try { fs.mkdirSync(dir, { recursive: true }); } catch { /* 已存在或不可建 */ }
|
|
905
|
-
fs.writeFileSync(tmp, JSON.stringify(
|
|
982
|
+
fs.writeFileSync(tmp, JSON.stringify(stamped), { mode: 0o600 });
|
|
906
983
|
try { if (typeof fs.chmodSync === 'function') fs.chmodSync(tmp, 0o600); } catch { /* Windows no-op */ }
|
|
907
984
|
fs.renameSync(tmp, final);
|
|
908
985
|
}
|
|
909
986
|
|
|
910
|
-
//
|
|
911
|
-
export function
|
|
912
|
-
let entries = [];
|
|
913
|
-
try { entries = fs.readdirSync(cacheDir); } catch { return []; }
|
|
914
|
-
const out = [];
|
|
915
|
-
for (const name of entries) {
|
|
916
|
-
const m = name.match(/^bugs-(.+)\.json$/);
|
|
917
|
-
if (m) out.push(m[1]);
|
|
918
|
-
}
|
|
919
|
-
return out;
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
// best-effort 更新某产品缓存中指定 bug;命中并写入返回 true,否则 false;失败静默
|
|
923
|
-
export function updateCachedBug(cacheDir, productId, bugId, patch, fs) {
|
|
987
|
+
// best-effort:在工作集内就地更新某 bug(写命令 resolve/assign 后),命中并写入返回 true;失败静默
|
|
988
|
+
export function updateWorkingSetBug(cacheDir, config, bugId, patch, fs) {
|
|
924
989
|
try {
|
|
925
|
-
const
|
|
926
|
-
if (!
|
|
990
|
+
const set = readWorkingSet(cacheDir, config, fs);
|
|
991
|
+
if (!set) return false;
|
|
927
992
|
let hit = false;
|
|
928
|
-
|
|
993
|
+
set.bugs = set.bugs.map((b) => {
|
|
929
994
|
if (String(b.id) === String(bugId)) { hit = true; return { ...b, ...patch }; }
|
|
930
995
|
return b;
|
|
931
996
|
});
|
|
932
|
-
if (hit)
|
|
997
|
+
if (hit) writeWorkingSet(cacheDir, config, set, fs);
|
|
933
998
|
return hit;
|
|
934
999
|
} catch {
|
|
935
1000
|
return false;
|
|
936
1001
|
}
|
|
937
1002
|
}
|
|
938
1003
|
|
|
939
|
-
// 扫描所有已缓存产品,更新命中 bug(写命令后 best-effort)
|
|
940
|
-
export function updateCachedBugAnywhere(cacheDir, bugId, patch, fs) {
|
|
941
|
-
let hit = false;
|
|
942
|
-
for (const pid of listCacheProducts(fs, cacheDir)) {
|
|
943
|
-
if (updateCachedBug(cacheDir, pid, bugId, patch, fs)) hit = true;
|
|
944
|
-
}
|
|
945
|
-
return hit;
|
|
946
|
-
}
|
|
947
|
-
|
|
948
1004
|
// 输出 browseType 合法值
|
|
949
1005
|
function cmdBugBrowseTypes(parsed) {
|
|
950
1006
|
const rows = BUG_BROWSE_TYPES;
|
|
@@ -956,20 +1012,28 @@ function cmdBugBrowseTypes(parsed) {
|
|
|
956
1012
|
return { stdout: JSON.stringify({ browseTypes: rows.map((r) => r.value) }, null, 2) };
|
|
957
1013
|
}
|
|
958
1014
|
|
|
959
|
-
// 一次性同步某产品 bug
|
|
1015
|
+
// 一次性同步某产品 bug 到本地【工作集】(整体替换,不再按产品分文件累加)
|
|
960
1016
|
async function cmdBugSync(parsed, client, config, { cacheDir, fs }) {
|
|
961
1017
|
let productId = parsed.flags.product || config.defaultProject;
|
|
1018
|
+
// 拉一次产品列表:名称前缀解析 + 存在性校验共用,避免重复请求
|
|
1019
|
+
const productList = await client.request('GET', '/api.php/v1/products');
|
|
1020
|
+
const products = productList.products || [];
|
|
1021
|
+
const knownIds = new Set(products.map((p) => String(p.id)));
|
|
962
1022
|
// --product 非数字 → 产品名前缀匹配
|
|
963
1023
|
if (productId && !/^\d+$/.test(String(productId))) {
|
|
964
|
-
const
|
|
965
|
-
const matches = (data.products || []).filter((p) => p.name && String(p.name).startsWith(productId));
|
|
1024
|
+
const matches = products.filter((p) => p.name && String(p.name).startsWith(productId));
|
|
966
1025
|
if (!matches.length) {
|
|
967
|
-
const names =
|
|
1026
|
+
const names = products.slice(0, 20).map((p) => p.name).filter(Boolean).join(', ');
|
|
968
1027
|
throw new ZentaoError('INVALID_PARAMETER', `未找到名前缀匹配 "${productId}" 的产品。已知产品: ${names || '(无)'}`);
|
|
969
1028
|
}
|
|
970
1029
|
productId = matches[0].id;
|
|
971
1030
|
}
|
|
972
1031
|
if (!productId) throw new ZentaoError('INVALID_PARAMETER', '未指定 --product 且无 ZENTAO_DEFAULT_PROJECT');
|
|
1032
|
+
// 存在性校验:禅道对不存在的 product id 会【静默回退】到别的产品吐数据,
|
|
1033
|
+
// 不校验就会把 A 产品的请求错当成 B 产品并缓存(曾导致切本地后仍显示老数据)。
|
|
1034
|
+
if (!knownIds.has(String(productId))) {
|
|
1035
|
+
throw new ZentaoError('INVALID_PARAMETER', `产品 ${productId} 在当前服务器不存在。可用产品 ID: ${[...knownIds].slice(0, 20).join(', ') || '(无)'}`);
|
|
1036
|
+
}
|
|
973
1037
|
const browseType = parsed.flags.status || 'unclosed'; // sync 的 --status = 服务端 browseType,默认 unclosed
|
|
974
1038
|
const limit = 100;
|
|
975
1039
|
const all = [];
|
|
@@ -980,10 +1044,11 @@ async function cmdBugSync(parsed, client, config, { cacheDir, fs }) {
|
|
|
980
1044
|
if (!data.bugs || data.bugs.length < limit) break; // 不足一页 = 取完
|
|
981
1045
|
}
|
|
982
1046
|
// --mine:本地按 assignedTo === 当前账号 过滤(可与任意 browseType 叠加)
|
|
983
|
-
const
|
|
984
|
-
const
|
|
985
|
-
|
|
986
|
-
|
|
1047
|
+
const mine = !!parsed.flags.mine;
|
|
1048
|
+
const final = mine ? all.filter((b) => b.assignedTo === config.username) : all;
|
|
1049
|
+
const set = { product: String(productId), status: browseType, mine, syncedAt: Date.now(), count: final.length, bugs: final };
|
|
1050
|
+
writeWorkingSet(cacheDir, config, set, fs); // 整体替换工作集(盖 url/account 戳);仅成功后写,失败不破坏旧工作集
|
|
1051
|
+
return { stdout: JSON.stringify({ product: String(productId), status: browseType, mine, syncedAt: set.syncedAt, count: final.length }, null, 2) };
|
|
987
1052
|
}
|
|
988
1053
|
|
|
989
1054
|
// 命令分发:根据 parsed 派发到对应 cmd,捕获 ZentaoError 转 exitCode
|
|
@@ -999,11 +1064,13 @@ export async function runCommand(parsed, { config, deps }) {
|
|
|
999
1064
|
else if (parsed.command === 'init') res = await cmdInit(parsed, config, deps);
|
|
1000
1065
|
else if (parsed.command === 'project' && parsed.sub === 'list') res = await cmdProjectList(parsed, client);
|
|
1001
1066
|
else if (parsed.command === 'product' && parsed.sub === 'list') res = await cmdProductList(parsed, client);
|
|
1067
|
+
else if (parsed.command === 'build' && parsed.sub === 'list') res = await cmdBuildList(parsed, client, config);
|
|
1002
1068
|
else if (parsed.command === 'bug' && (parsed.sub === 'list' || parsed.sub === 'search')) res = await cmdBugList(parsed, config, deps);
|
|
1003
1069
|
else if (parsed.command === 'bug' && parsed.sub === 'get') res = await cmdBugGet(parsed, client);
|
|
1004
|
-
else if (parsed.command === 'bug' && parsed.sub === 'create') res = await cmdBugCreate(parsed, client, config
|
|
1005
|
-
else if (parsed.command === 'bug' && parsed.sub === 'resolve') res = await cmdBugResolve(parsed, client, deps);
|
|
1006
|
-
else if (parsed.command === 'bug' && parsed.sub === 'assign') res = await cmdBugAssign(parsed, client, deps);
|
|
1070
|
+
else if (parsed.command === 'bug' && parsed.sub === 'create') res = await cmdBugCreate(parsed, client, config);
|
|
1071
|
+
else if (parsed.command === 'bug' && parsed.sub === 'resolve') res = await cmdBugResolve(parsed, client, deps, config);
|
|
1072
|
+
else if (parsed.command === 'bug' && parsed.sub === 'assign') res = await cmdBugAssign(parsed, client, deps, config);
|
|
1073
|
+
else if (parsed.command === 'bug' && parsed.sub === 'confirm') res = await cmdBugConfirm(parsed, client, deps, config);
|
|
1007
1074
|
else if (parsed.command === 'bug' && parsed.sub === 'sync') res = await cmdBugSync(parsed, client, config, deps);
|
|
1008
1075
|
else if (parsed.command === 'bug' && parsed.sub === 'browse-types') res = cmdBugBrowseTypes(parsed);
|
|
1009
1076
|
else if (parsed.command === 'ping') res = await cmdPing(parsed, client, config);
|