i18-fe-automator-beta 2.1.4 → 2.1.6
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/dist/commonjs/index.js +576 -102
- package/dist/esm/index.mjs +576 -102
- package/package.json +1 -1
package/dist/commonjs/index.js
CHANGED
|
@@ -1809,6 +1809,16 @@ const CACHE_KEY = "zentao"; // .cache/zentao: { account, password, zentaosid, pr
|
|
|
1809
1809
|
const PLAN_STORY_TABLE_LIMIT = 50;
|
|
1810
1810
|
const PULL_CONCURRENCY = 5;
|
|
1811
1811
|
|
|
1812
|
+
// 任务状态中文标签(close 命令展示用)
|
|
1813
|
+
const TASK_STATUS_LABELS = {
|
|
1814
|
+
wait: "未开始",
|
|
1815
|
+
doing: "进行中",
|
|
1816
|
+
pause: "已暂停",
|
|
1817
|
+
done: "已完成",
|
|
1818
|
+
closed: "已关闭",
|
|
1819
|
+
cancel: "已取消",
|
|
1820
|
+
};
|
|
1821
|
+
|
|
1812
1822
|
// 确保 .cache 目录存在
|
|
1813
1823
|
function ensureCacheDir() {
|
|
1814
1824
|
const cacheDir = getRunCliPath({ root: true }) + "/.cache";
|
|
@@ -1934,6 +1944,8 @@ function zentaoRequest({ route, zentaosid, qs }) {
|
|
|
1934
1944
|
* - message 校验失败时是对象(如 {estStarted: [『预计开始』不能为空。]}), 统一格式化为文本
|
|
1935
1945
|
* - form 字段名必须对齐表单真实 name(如 assignedTo[]): 实测传多余字段(mailto/after/hiddenwin)
|
|
1936
1946
|
* 或 desc 含 HTML 标签时, 会出现"返回保存成功但未落库"或空响应, 只传验证过的最小字段集
|
|
1947
|
+
* - 非JSON响应分类(实测禅道部分写接口成功也返回 JS跳转页而非JSON信封):
|
|
1948
|
+
* alert脚本=业务拦截(提取文案作真实错误) | 脚本location跳转: 跳登录页=会话失效, 跳其他页(如 task-view-x)=操作成功 | 其余带响应片段报错
|
|
1937
1949
|
*/
|
|
1938
1950
|
function zentaoPost({ route, zentaosid, form }) {
|
|
1939
1951
|
return new Promise((resolve, reject) => {
|
|
@@ -1953,30 +1965,56 @@ function zentaoPost({ route, zentaosid, form }) {
|
|
|
1953
1965
|
(error, _response, body) => {
|
|
1954
1966
|
if (error) return reject(error);
|
|
1955
1967
|
const text = String(body).replace(/^\uFEFF/, "");
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1968
|
+
// 先尝试按JSON信封解析(<html开头的响应跳过解析, 走下方非JSON分类)
|
|
1969
|
+
let envelope = null;
|
|
1970
|
+
if (!/^\s*<html/i.test(text)) {
|
|
1971
|
+
try {
|
|
1972
|
+
envelope = JSON.parse(text);
|
|
1973
|
+
} catch (e) {
|
|
1974
|
+
/* 非JSON, 走下方非JSON分类 */
|
|
1975
|
+
}
|
|
1960
1976
|
}
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1977
|
+
if (envelope && typeof envelope === "object") {
|
|
1978
|
+
if (envelope.result === "success") {
|
|
1979
|
+
return resolve(envelope);
|
|
1980
|
+
}
|
|
1981
|
+
const message = envelope.message;
|
|
1982
|
+
const detail =
|
|
1983
|
+
typeof message === "string"
|
|
1984
|
+
? message
|
|
1985
|
+
: Object.values(message || {})
|
|
1986
|
+
.map((v) => (Array.isArray(v) ? v.join("; ") : String(v)))
|
|
1987
|
+
.join("; ");
|
|
1988
|
+
return reject(new Error(detail || `禅道接口返回失败: ${JSON.stringify(envelope)}`));
|
|
1989
|
+
}
|
|
1990
|
+
// 非JSON响应分类:
|
|
1991
|
+
// 1) alert脚本: 业务规则拦截(如字段校验失败"本次消耗必须为数字"), 提取alert文案作为真实错误
|
|
1992
|
+
const alertMatch = text.match(/alert\((['"])([\s\S]*?)\1\)/);
|
|
1993
|
+
if (alertMatch) {
|
|
1994
|
+
return reject(new Error(alertMatch[2]));
|
|
1995
|
+
}
|
|
1996
|
+
// 2) 脚本location跳转: 跳登录页=会话失效; 跳其他页(如 task-view-x)=操作成功(旧式 die(js::locate) 成功响应)
|
|
1997
|
+
const locateMatch = text.match(
|
|
1998
|
+
/(?:parent|self|window|top)\.location(?:\.href)?\s*=\s*['"]([^'"]+)['"]/
|
|
1999
|
+
);
|
|
2000
|
+
if (locateMatch) {
|
|
2001
|
+
if (/user-login/.test(locateMatch[1])) {
|
|
2002
|
+
return reject(
|
|
2003
|
+
Object.assign(new Error("禅道会话已失效"), { isAuthError: true })
|
|
2004
|
+
);
|
|
2005
|
+
}
|
|
2006
|
+
return resolve({ result: "success", locate: locateMatch[1] });
|
|
2007
|
+
}
|
|
2008
|
+
// 3) 兜底登录页字样(无脚本跳转的登录页响应, POST响应不含用户数据可安全判定)
|
|
2009
|
+
if (/user-login/.test(text)) {
|
|
1965
2010
|
return reject(
|
|
1966
2011
|
Object.assign(new Error("禅道会话已失效"), { isAuthError: true })
|
|
1967
2012
|
);
|
|
1968
2013
|
}
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
const detail =
|
|
1974
|
-
typeof message === "string"
|
|
1975
|
-
? message
|
|
1976
|
-
: Object.values(message || {})
|
|
1977
|
-
.map((v) => (Array.isArray(v) ? v.join("; ") : String(v)))
|
|
1978
|
-
.join("; ");
|
|
1979
|
-
return reject(new Error(detail || `禅道接口返回失败: ${JSON.stringify(envelope)}`));
|
|
2014
|
+
// 4) 其余异常: 带响应片段便于定位
|
|
2015
|
+
return reject(
|
|
2016
|
+
new Error(`禅道POST响应异常(非JSON): ${text.slice(0, 200)}`)
|
|
2017
|
+
);
|
|
1980
2018
|
}
|
|
1981
2019
|
);
|
|
1982
2020
|
});
|
|
@@ -2177,7 +2215,7 @@ async function selectProduct(zentaosid, cliOpts) {
|
|
|
2177
2215
|
name: `#${p.id} ${p.name}`,
|
|
2178
2216
|
value: p.id,
|
|
2179
2217
|
})),
|
|
2180
|
-
{ name: "
|
|
2218
|
+
{ name: "输入其他产品-计划ID(如: https://zentao.hongxinshop.com/zentao/productplan-browse-29.html 中的29)", value: "__INPUT__" },
|
|
2181
2219
|
],
|
|
2182
2220
|
},
|
|
2183
2221
|
]);
|
|
@@ -2188,11 +2226,11 @@ async function selectProduct(zentaosid, cliOpts) {
|
|
|
2188
2226
|
}
|
|
2189
2227
|
const { id } = await inquirer.prompt([
|
|
2190
2228
|
{
|
|
2191
|
-
message: "
|
|
2229
|
+
message: "请输入产品计划ID(如: https://zentao.hongxinshop.com/zentao/productplan-browse-29.html 中的29)",
|
|
2192
2230
|
name: "id",
|
|
2193
2231
|
type: "input",
|
|
2194
2232
|
validate: (val) =>
|
|
2195
|
-
/^\d+$/.test(String(val).trim()) || "
|
|
2233
|
+
/^\d+$/.test(String(val).trim()) || "请输入数字产品计划ID",
|
|
2196
2234
|
},
|
|
2197
2235
|
]);
|
|
2198
2236
|
return resolveProduct(zentaosid, id);
|
|
@@ -2267,6 +2305,18 @@ function fetchTaskDetail(zentaosid, taskID) {
|
|
|
2267
2305
|
);
|
|
2268
2306
|
}
|
|
2269
2307
|
|
|
2308
|
+
// 拉项目中指派给登录账号的任务列表(myinvolved页, close命令用)
|
|
2309
|
+
// 注: 与任务列表一致, 子任务不出现在列表(挂父任务children下), 父任务需逐个拉详情拿子任务
|
|
2310
|
+
function fetchMyInvolvedTasks(zentaosid, projectID) {
|
|
2311
|
+
return zentaoRequest({
|
|
2312
|
+
route: `project-task-${projectID}-myinvolved`,
|
|
2313
|
+
zentaosid,
|
|
2314
|
+
}).then((data) => ({
|
|
2315
|
+
tasks: Object.values(data.tasks || {}),
|
|
2316
|
+
pageTotal: Number(data.pager?.pageTotal || 1),
|
|
2317
|
+
}));
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2270
2320
|
/**
|
|
2271
2321
|
* 在项目下为需求创建任务(实测协议, 12.3.1): POST task-create-{projectID}-{storyID}.json
|
|
2272
2322
|
* - estStarted/deadline 必填(缺了返回 fail + 字段错误提示)
|
|
@@ -2365,6 +2415,38 @@ function createZentaoSubtasks({ zentaosid, projectID, story, parentTaskID, type,
|
|
|
2365
2415
|
});
|
|
2366
2416
|
}
|
|
2367
2417
|
|
|
2418
|
+
/**
|
|
2419
|
+
* 完成任务: POST task-finish-{taskID}.json (close 命令用)
|
|
2420
|
+
* - "本次消耗"字段名实测为 currentConsumed(本次增量, 服务端自动累计到总消耗);
|
|
2421
|
+
* 传 consumed 会报 ""本次消耗"必须为数字"(该字段没上送被校验为空)
|
|
2422
|
+
* - 完成人/指派人字段不传: 禅道服务端完成任务时强制置 finishedBy=当前登录人、
|
|
2423
|
+
* assignedTo='closed'(页面"指派给"列显示 Closed), 传了也会被覆盖, 不如不传
|
|
2424
|
+
* - 完成后状态置 done; 已 done 的无需 finish 直接 close
|
|
2425
|
+
*/
|
|
2426
|
+
function finishZentaoTask({ zentaosid, taskID, consumed }) {
|
|
2427
|
+
return zentaoPost({
|
|
2428
|
+
route: `task-finish-${taskID}`,
|
|
2429
|
+
zentaosid,
|
|
2430
|
+
form: {
|
|
2431
|
+
currentConsumed: String(Number(consumed) || 0),
|
|
2432
|
+
comment: "",
|
|
2433
|
+
uid: String(Date.now()),
|
|
2434
|
+
},
|
|
2435
|
+
});
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
// 关闭任务: POST task-close-{taskID}.json, 只传 comment 最小字段集(close 命令用)
|
|
2439
|
+
function closeZentaoTask({ zentaosid, taskID }) {
|
|
2440
|
+
return zentaoPost({
|
|
2441
|
+
route: `task-close-${taskID}`,
|
|
2442
|
+
zentaosid,
|
|
2443
|
+
form: {
|
|
2444
|
+
comment: "",
|
|
2445
|
+
uid: String(Date.now()),
|
|
2446
|
+
},
|
|
2447
|
+
});
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2368
2450
|
// 本地时区当天日期(YYYY-MM-DD, 任务estStarted用; toISOString是UTC会差8小时)
|
|
2369
2451
|
function localToday() {
|
|
2370
2452
|
const d = new Date();
|
|
@@ -2543,6 +2625,11 @@ async function downloadStoryImages({ stories, storyDetails, zentaosid, zentaoDir
|
|
|
2543
2625
|
mapping.set(src, ref);
|
|
2544
2626
|
};
|
|
2545
2627
|
let index = 0;
|
|
2628
|
+
let imgDone = 0;
|
|
2629
|
+
const failedLogs = [];
|
|
2630
|
+
if (jobs.length) {
|
|
2631
|
+
Loading$1.start(`下载需求图片 (0/${jobs.length})...`);
|
|
2632
|
+
}
|
|
2546
2633
|
const worker = async () => {
|
|
2547
2634
|
while (index < jobs.length) {
|
|
2548
2635
|
const { story, src } = jobs[index];
|
|
@@ -2555,15 +2642,25 @@ async function downloadStoryImages({ stories, storyDetails, zentaosid, zentaoDir
|
|
|
2555
2642
|
fs.writeFileSync(target, buf);
|
|
2556
2643
|
setMapping(story.id, src, `images/${fileName}`);
|
|
2557
2644
|
} catch (e) {
|
|
2558
|
-
|
|
2559
|
-
|
|
2645
|
+
// 失败提示延后到spinner结束统一输出(避免与spinner交错)
|
|
2646
|
+
failedLogs.push(
|
|
2647
|
+
`需求 ${story.id} 图片下载失败(${src}): ${e.message}, 该图保留远程链接`
|
|
2560
2648
|
);
|
|
2561
2649
|
}
|
|
2650
|
+
imgDone += 1;
|
|
2651
|
+
// ora 的 text 是属性而非方法
|
|
2652
|
+
Loading$1.text = `下载需求图片 (${imgDone}/${jobs.length})...`;
|
|
2562
2653
|
}
|
|
2563
2654
|
};
|
|
2564
2655
|
await Promise.all(
|
|
2565
2656
|
Array.from({ length: PULL_CONCURRENCY }, () => worker())
|
|
2566
2657
|
);
|
|
2658
|
+
if (jobs.length) {
|
|
2659
|
+
Loading$1.succeed(
|
|
2660
|
+
`下载需求图片完成(${jobs.length - failedLogs.length}/${jobs.length})`
|
|
2661
|
+
);
|
|
2662
|
+
}
|
|
2663
|
+
failedLogs.forEach((line) => console.log(chalk.yellow(line)));
|
|
2567
2664
|
return result;
|
|
2568
2665
|
}
|
|
2569
2666
|
|
|
@@ -2757,7 +2854,7 @@ function writeZentaoTasks({ plan, scope, stories, storyDetails, products, failed
|
|
|
2757
2854
|
}
|
|
2758
2855
|
}
|
|
2759
2856
|
|
|
2760
|
-
// 计划选取交互: 关键词过滤 > 命中1条自动选中 > rawlist选取
|
|
2857
|
+
// 计划选取交互: 关键词过滤(标题) > 命中1条自动选中 > rawlist选取
|
|
2761
2858
|
async function selectPlan(plans, keyword) {
|
|
2762
2859
|
if (keyword && String(keyword).trim()) {
|
|
2763
2860
|
const kw = String(keyword).trim().toLowerCase();
|
|
@@ -2802,10 +2899,15 @@ async function loadPlanList(zentaosid, productID) {
|
|
|
2802
2899
|
return plans;
|
|
2803
2900
|
}
|
|
2804
2901
|
|
|
2805
|
-
// plan动作: 选中计划后展示计划信息 + 关联需求列表(ID供detail用)
|
|
2902
|
+
// plan动作: 选中计划后展示计划信息 + 关联需求列表(ID供detail用); 返回关联需求(供调用方判断空计划)
|
|
2806
2903
|
async function showPlanDetail(zentaosid, selected) {
|
|
2807
2904
|
Loading$1.start("拉取计划详情中...");
|
|
2808
2905
|
const { plan, stories } = await fetchPlanDetail(zentaosid, selected.id);
|
|
2906
|
+
// 0条需求时详情/工时统计均无意义, 一句话结束
|
|
2907
|
+
if (!stories.length) {
|
|
2908
|
+
Loading$1.succeed(`计划 #${plan.id} ${plan.title} 无关联需求`);
|
|
2909
|
+
return stories;
|
|
2910
|
+
}
|
|
2809
2911
|
Loading$1.succeed("拉取计划详情成功");
|
|
2810
2912
|
console.log(chalk.bold("\n计划信息"));
|
|
2811
2913
|
console.table([
|
|
@@ -2860,6 +2962,7 @@ async function showPlanDetail(zentaosid, selected) {
|
|
|
2860
2962
|
console.log(
|
|
2861
2963
|
chalk.blue("提示: ID 供 fe-it-beta story detail <需求ID> 使用(也可传需求url)")
|
|
2862
2964
|
);
|
|
2965
|
+
return stories;
|
|
2863
2966
|
}
|
|
2864
2967
|
|
|
2865
2968
|
// 导出动作(plan收尾交互使用): 按范围过滤需求, 拉详情并产出 .zentao-<计划名>/ + 工作区
|
|
@@ -2959,13 +3062,13 @@ async function exportPlanStories(zentaosid, selected, scope) {
|
|
|
2959
3062
|
|
|
2960
3063
|
/**
|
|
2961
3064
|
* story 主入口
|
|
2962
|
-
* @param action login | plan | detail | task | subtask
|
|
3065
|
+
* @param action login | plan | detail | task | subtask | close
|
|
2963
3066
|
* @param cliOpts 合并后的命令参数(name/username/password/cache/export...)
|
|
2964
3067
|
*/
|
|
2965
3068
|
async function storyMain(action, cliOpts) {
|
|
2966
3069
|
validateChoice(
|
|
2967
3070
|
action,
|
|
2968
|
-
["login", "plan", "detail", "task", "subtask"],
|
|
3071
|
+
["login", "plan", "detail", "task", "subtask", "close"],
|
|
2969
3072
|
"story <action>"
|
|
2970
3073
|
);
|
|
2971
3074
|
if (action === "login") {
|
|
@@ -3028,7 +3131,7 @@ async function storyMain(action, cliOpts) {
|
|
|
3028
3131
|
if (!match) {
|
|
3029
3132
|
console.log(
|
|
3030
3133
|
chalk.red(
|
|
3031
|
-
"
|
|
3134
|
+
"请传入项目-需求url的ID, 如: fe-it-beta story task 2915 或 fe-it-beta story task https://zentao.hongxinshop.com/zentao/project-story-2915.html"
|
|
3032
3135
|
)
|
|
3033
3136
|
);
|
|
3034
3137
|
process.exit(1);
|
|
@@ -3090,11 +3193,17 @@ async function storyMain(action, cliOpts) {
|
|
|
3090
3193
|
const totalCount = (s) => tasksOf(s).length;
|
|
3091
3194
|
// 子任务统计: 子任务不出现在任务列表(挂在父任务children下), 逐个拉详情;
|
|
3092
3195
|
// 只拉 工时>8 的主任务(只有这些才可能拆过子任务), 并发限流控制请求量
|
|
3196
|
+
// 注: parent 字段三态(实测): 0=独立任务 | -1=自己是父任务(有子任务) | >0=挂在别人下的子任务
|
|
3197
|
+
// 候选排除子任务即可(parent>0), -1 的父任务必须纳入(它才有children可统计)
|
|
3093
3198
|
const subCountMap = new Map(); // 任务ID -> 子任务数
|
|
3094
3199
|
{
|
|
3095
3200
|
const candidates = projectTasks.filter(
|
|
3096
|
-
(t) => Number(t.estimate) > 8 && !Number(t.parent)
|
|
3201
|
+
(t) => Number(t.estimate) > 8 && !(Number(t.parent) > 0)
|
|
3097
3202
|
);
|
|
3203
|
+
if (candidates.length) {
|
|
3204
|
+
Loading$1.start(`统计子任务 (0/${candidates.length})...`);
|
|
3205
|
+
}
|
|
3206
|
+
let subDone = 0;
|
|
3098
3207
|
let index = 0;
|
|
3099
3208
|
const worker = async () => {
|
|
3100
3209
|
while (index < candidates.length) {
|
|
@@ -3108,11 +3217,17 @@ async function storyMain(action, cliOpts) {
|
|
|
3108
3217
|
if (e?.isAuthError) throw e;
|
|
3109
3218
|
// 子任务统计失败不阻塞主流程(勾选列表仍可用, 只是少了子任务数)
|
|
3110
3219
|
}
|
|
3220
|
+
subDone += 1;
|
|
3221
|
+
// ora 的 text 是属性而非方法
|
|
3222
|
+
Loading$1.text = `统计子任务 (${subDone}/${candidates.length})...`;
|
|
3111
3223
|
}
|
|
3112
3224
|
};
|
|
3113
3225
|
await Promise.all(
|
|
3114
3226
|
Array.from({ length: PULL_CONCURRENCY }, () => worker())
|
|
3115
3227
|
);
|
|
3228
|
+
if (candidates.length) {
|
|
3229
|
+
Loading$1.succeed("统计子任务完成");
|
|
3230
|
+
}
|
|
3116
3231
|
}
|
|
3117
3232
|
// 先勾选需求: 一键(--type直通)自动取候选; 交互则列出项目全部需求
|
|
3118
3233
|
let picked;
|
|
@@ -3273,6 +3388,13 @@ async function storyMain(action, cliOpts) {
|
|
|
3273
3388
|
const account = readZentaoCache().account;
|
|
3274
3389
|
const assigneeOf = (s) =>
|
|
3275
3390
|
(taskType === "front" ? s.webuser : s.devuser) || account;
|
|
3391
|
+
// 现有父任务(pick中已建该类任务的): 拆分类模式复用, 不重复建
|
|
3392
|
+
const existingParentOf = (s) =>
|
|
3393
|
+
tasksOf(s).find((t) =>
|
|
3394
|
+
taskType === "front"
|
|
3395
|
+
? isFrontTask(t)
|
|
3396
|
+
: t.type === "devel" && !isFrontTask(t)
|
|
3397
|
+
);
|
|
3276
3398
|
// 处理模式(选择即确认): --split-only > --split > 交互列表 > 一键默认
|
|
3277
3399
|
// parent=仅创建父任务 | parent-split=创建父任务+拆分子任务 | split=仅拆分(无父任务自动先建)
|
|
3278
3400
|
let taskMode;
|
|
@@ -3282,6 +3404,18 @@ async function storyMain(action, cliOpts) {
|
|
|
3282
3404
|
taskMode = "parent-split";
|
|
3283
3405
|
} else if (cliOpts.type !== undefined) {
|
|
3284
3406
|
taskMode = "parent"; // 一键默认仅建父任务
|
|
3407
|
+
} else if (picked.every((s) => existingParentOf(s))) {
|
|
3408
|
+
// 全部已有父任务: 无需再建父任务, 仅展示补拆选项(剩余工时拆满, 部分已拆的补齐)
|
|
3409
|
+
({ taskMode } = await inquirer.prompt([
|
|
3410
|
+
{
|
|
3411
|
+
message: `将处理 ${picked.length} 条【${typeLabel}】任务(截止 ${pj.project.end}), 请选择模式`,
|
|
3412
|
+
name: "taskMode",
|
|
3413
|
+
type: "list",
|
|
3414
|
+
choices: [
|
|
3415
|
+
{ name: "1. 拆分子任务(按剩余工时补满)", value: "split" },
|
|
3416
|
+
],
|
|
3417
|
+
},
|
|
3418
|
+
]));
|
|
3285
3419
|
} else {
|
|
3286
3420
|
({ taskMode } = await inquirer.prompt([
|
|
3287
3421
|
{
|
|
@@ -3296,13 +3430,6 @@ async function storyMain(action, cliOpts) {
|
|
|
3296
3430
|
},
|
|
3297
3431
|
]));
|
|
3298
3432
|
}
|
|
3299
|
-
// 现有父任务(pick中已建该类任务的): 拆分类模式复用, 不重复建
|
|
3300
|
-
const existingParentOf = (s) =>
|
|
3301
|
-
tasksOf(s).find((t) =>
|
|
3302
|
-
taskType === "front"
|
|
3303
|
-
? isFrontTask(t)
|
|
3304
|
-
: t.type === "devel" && !isFrontTask(t)
|
|
3305
|
-
);
|
|
3306
3433
|
// 需创建父任务的需求:
|
|
3307
3434
|
// parent=全部勾选 | parent-split=无父任务的 | split=无父任务且>8h(仅为拆分而建)
|
|
3308
3435
|
const toCreate =
|
|
@@ -3314,11 +3441,13 @@ async function storyMain(action, cliOpts) {
|
|
|
3314
3441
|
(taskMode === "parent-split" || hourOf(s) > 8)
|
|
3315
3442
|
);
|
|
3316
3443
|
// 创建前任务基线(创建后 diff 出新任务ID)
|
|
3444
|
+
Loading$1.start("同步任务基线中...");
|
|
3317
3445
|
const baseline = new Set(
|
|
3318
3446
|
(await fetchProjectTasks(zentaosid, projectID)).tasks.map((t) =>
|
|
3319
3447
|
String(t.id)
|
|
3320
3448
|
)
|
|
3321
3449
|
);
|
|
3450
|
+
Loading$1.succeed("任务基线就绪");
|
|
3322
3451
|
// 逐条创建(串行, 避免并发触发禅道限流)
|
|
3323
3452
|
let done = 0;
|
|
3324
3453
|
const failedStories = [];
|
|
@@ -3356,9 +3485,11 @@ async function storyMain(action, cliOpts) {
|
|
|
3356
3485
|
}
|
|
3357
3486
|
// 重拉任务列表 diff 出新任务, 输出任务url
|
|
3358
3487
|
const pickedIDs = new Set(picked.map((s) => String(s.id)));
|
|
3488
|
+
Loading$1.start("刷新任务列表中...");
|
|
3359
3489
|
const newTasks = (await fetchProjectTasks(zentaosid, projectID)).tasks.filter(
|
|
3360
3490
|
(t) => !baseline.has(String(t.id)) && pickedIDs.has(String(t.story))
|
|
3361
3491
|
);
|
|
3492
|
+
Loading$1.succeed(`刷新完成(新任务 ${newTasks.length} 条)`);
|
|
3362
3493
|
newTasks.forEach((t) =>
|
|
3363
3494
|
console.log(
|
|
3364
3495
|
chalk.green(
|
|
@@ -3372,70 +3503,85 @@ async function storyMain(action, cliOpts) {
|
|
|
3372
3503
|
const newByStory = new Map(
|
|
3373
3504
|
newTasks.map((t) => [String(t.story), t])
|
|
3374
3505
|
);
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
chalk.yellow(
|
|
3385
|
-
`任务 #${parent.id} ${parent.name} 截止日期 ${deadline} 不晚于今天, 跳过拆分(需先调整父任务截止日期)`
|
|
3386
|
-
)
|
|
3387
|
-
);
|
|
3388
|
-
continue;
|
|
3389
|
-
}
|
|
3390
|
-
// 剩余工时: 新建父任务=全部工时; 已有父任务=工时-已有子任务和(拉详情算)
|
|
3391
|
-
let remaining = hourOf(s);
|
|
3392
|
-
if (!created) {
|
|
3393
|
-
const detail = await fetchTaskDetail(zentaosid, parent.id);
|
|
3394
|
-
const childHours = Object.values(detail?.children || {}).reduce(
|
|
3395
|
-
(sum, c) => sum + Number(c.estimate || 0),
|
|
3396
|
-
0
|
|
3397
|
-
);
|
|
3398
|
-
remaining = Number(parent.estimate) - childHours;
|
|
3399
|
-
}
|
|
3400
|
-
if (!(remaining > 8)) {
|
|
3401
|
-
console.log(
|
|
3402
|
-
chalk.yellow(
|
|
3403
|
-
`任务 #${parent.id} ${parent.name} 剩余工时 ${remaining}h 未超过 8h, 跳过拆分`
|
|
3404
|
-
)
|
|
3405
|
-
);
|
|
3406
|
-
continue;
|
|
3407
|
-
}
|
|
3408
|
-
const subs = splitTaskPlan(parent.name, remaining, taskType);
|
|
3506
|
+
// 拆分候选(仅>8h); 执行期间只显示spinner进度, 结果收集到splitLogs结束后统一输出
|
|
3507
|
+
const splitCandidates = picked.filter((s) => hourOf(s) > 8);
|
|
3508
|
+
const splitLogs = [];
|
|
3509
|
+
let splitDone = 0;
|
|
3510
|
+
if (splitCandidates.length) {
|
|
3511
|
+
Loading$1.start(`拆分子任务 (0/${splitCandidates.length})...`);
|
|
3512
|
+
}
|
|
3513
|
+
for (const s of splitCandidates) {
|
|
3514
|
+
let parent = null;
|
|
3409
3515
|
try {
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
}
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3516
|
+
const created = newByStory.get(String(s.id));
|
|
3517
|
+
parent = created || existingParentOf(s);
|
|
3518
|
+
if (!parent) {
|
|
3519
|
+
// 创建失败等场景, 无父任务可挂, 跳过
|
|
3520
|
+
} else if (String(parent.deadline || pj.project.end) <= localToday()) {
|
|
3521
|
+
// 截止日期须晚于今天(禅道校验 截止>开始, 旧任务截止已过期的跳过)
|
|
3522
|
+
splitLogs.push(
|
|
3523
|
+
chalk.yellow(
|
|
3524
|
+
`任务 #${parent.id} ${parent.name} 截止日期 ${parent.deadline || pj.project.end} 不晚于今天, 跳过拆分(需先调整父任务截止日期)`
|
|
3525
|
+
)
|
|
3526
|
+
);
|
|
3527
|
+
} else {
|
|
3528
|
+
// 剩余工时: 新建父任务=全部工时; 已有父任务=工时-已有子任务和(拉详情算)
|
|
3529
|
+
let remaining = hourOf(s);
|
|
3530
|
+
if (!created) {
|
|
3531
|
+
const detail = await fetchTaskDetail(zentaosid, parent.id);
|
|
3532
|
+
const childHours = Object.values(detail?.children || {}).reduce(
|
|
3533
|
+
(sum, c) => sum + Number(c.estimate || 0),
|
|
3534
|
+
0
|
|
3535
|
+
);
|
|
3536
|
+
remaining = Number(parent.estimate) - childHours;
|
|
3537
|
+
}
|
|
3538
|
+
if (!(remaining > 8)) {
|
|
3539
|
+
splitLogs.push(
|
|
3540
|
+
chalk.yellow(
|
|
3541
|
+
`任务 #${parent.id} ${parent.name} 剩余工时 ${remaining}h 未超过 8h, 跳过拆分`
|
|
3542
|
+
)
|
|
3543
|
+
);
|
|
3544
|
+
} else {
|
|
3545
|
+
const subs = splitTaskPlan(parent.name, remaining, taskType);
|
|
3546
|
+
await createZentaoSubtasks({
|
|
3547
|
+
zentaosid,
|
|
3548
|
+
projectID,
|
|
3549
|
+
story: s,
|
|
3550
|
+
parentTaskID: parent.id,
|
|
3551
|
+
type: taskType === "front" ? "frontend" : "devel",
|
|
3552
|
+
assignedTo: parent.assignedTo,
|
|
3553
|
+
estStarted: localToday(),
|
|
3554
|
+
// 截止日期跟随父任务(新建父任务即为项目结束日期)
|
|
3555
|
+
deadline: parent.deadline || pj.project.end,
|
|
3556
|
+
subs,
|
|
3557
|
+
});
|
|
3558
|
+
splitLogs.push(
|
|
3559
|
+
chalk.green(
|
|
3560
|
+
`子任务 ${subs.length} 个已创建(父任务 #${parent.id}): ${subs
|
|
3561
|
+
.map((sub) => `${sub.name}(${sub.estimate}h)`)
|
|
3562
|
+
.join(", ")}`
|
|
3563
|
+
)
|
|
3564
|
+
);
|
|
3565
|
+
}
|
|
3566
|
+
}
|
|
3429
3567
|
} catch (e) {
|
|
3430
3568
|
// 会话失效上抛, 由 ensureZentaoSession 整体重登重试
|
|
3431
3569
|
if (e?.isAuthError) throw e;
|
|
3432
|
-
|
|
3570
|
+
splitLogs.push(
|
|
3433
3571
|
chalk.yellow(
|
|
3434
|
-
`任务
|
|
3572
|
+
`任务 #${parent?.id || s.id} 子任务创建失败: ${e.message || String(e)}`
|
|
3435
3573
|
)
|
|
3436
3574
|
);
|
|
3575
|
+
} finally {
|
|
3576
|
+
splitDone += 1;
|
|
3577
|
+
// ora 的 text 是属性而非方法
|
|
3578
|
+
Loading$1.text = `拆分子任务 (${splitDone}/${splitCandidates.length})...`;
|
|
3437
3579
|
}
|
|
3438
3580
|
}
|
|
3581
|
+
if (splitCandidates.length) {
|
|
3582
|
+
Loading$1.succeed("拆分子任务完成");
|
|
3583
|
+
splitLogs.forEach((line) => console.log(line));
|
|
3584
|
+
}
|
|
3439
3585
|
}
|
|
3440
3586
|
// 部分失败以非零退出码收尾, 供自动化流程感知重试
|
|
3441
3587
|
if (failedStories.length) {
|
|
@@ -3504,9 +3650,36 @@ async function storyMain(action, cliOpts) {
|
|
|
3504
3650
|
);
|
|
3505
3651
|
return;
|
|
3506
3652
|
}
|
|
3653
|
+
// 截止日期须晚于预计开始(今天): 父任务截止已过期时禅道会校验失败, 交互输入子任务新截止日期
|
|
3654
|
+
let deadline = parent.deadline;
|
|
3655
|
+
if (String(deadline) <= localToday()) {
|
|
3656
|
+
console.log(
|
|
3657
|
+
chalk.yellow(
|
|
3658
|
+
`父任务截止日期 ${deadline} 已不晚于今天, 子任务截止日期需重新指定`
|
|
3659
|
+
)
|
|
3660
|
+
);
|
|
3661
|
+
const after7 = new Date();
|
|
3662
|
+
after7.setDate(after7.getDate() + 7);
|
|
3663
|
+
const defaultDeadline = `${after7.getFullYear()}-${String(
|
|
3664
|
+
after7.getMonth() + 1
|
|
3665
|
+
).padStart(2, "0")}-${String(after7.getDate()).padStart(2, "0")}`;
|
|
3666
|
+
({ deadline } = await inquirer.prompt([
|
|
3667
|
+
{
|
|
3668
|
+
message: "请输入子任务截止日期(YYYY-MM-DD)",
|
|
3669
|
+
name: "deadline",
|
|
3670
|
+
type: "input",
|
|
3671
|
+
default: defaultDeadline,
|
|
3672
|
+
validate: (val) =>
|
|
3673
|
+
(/^\d{4}-\d{2}-\d{2}$/.test(String(val).trim()) &&
|
|
3674
|
+
String(val).trim() > localToday()) ||
|
|
3675
|
+
"请输入晚于今天的日期, 格式 YYYY-MM-DD",
|
|
3676
|
+
},
|
|
3677
|
+
]));
|
|
3678
|
+
deadline = String(deadline).trim();
|
|
3679
|
+
}
|
|
3507
3680
|
// 拆分方案(按剩余工时, 名称对齐父任务名)
|
|
3508
3681
|
const subs = splitTaskPlan(parent.name, remainHours, taskType);
|
|
3509
|
-
console.log(chalk.bold(`\n拆分方案(${subs.length} 个子任务, 共${remainHours}h):`));
|
|
3682
|
+
console.log(chalk.bold(`\n拆分方案(${subs.length} 个子任务, 共${remainHours}h, 截止 ${deadline}):`));
|
|
3510
3683
|
subs.forEach((sub) =>
|
|
3511
3684
|
console.log(` ${sub.name} (${sub.estimate}h) -> ${parent.assignedTo}`)
|
|
3512
3685
|
);
|
|
@@ -3525,6 +3698,7 @@ async function storyMain(action, cliOpts) {
|
|
|
3525
3698
|
return;
|
|
3526
3699
|
}
|
|
3527
3700
|
}
|
|
3701
|
+
Loading$1.start("创建子任务中...");
|
|
3528
3702
|
await createZentaoSubtasks({
|
|
3529
3703
|
zentaosid,
|
|
3530
3704
|
projectID: parent.project,
|
|
@@ -3533,9 +3707,10 @@ async function storyMain(action, cliOpts) {
|
|
|
3533
3707
|
type: parent.type,
|
|
3534
3708
|
assignedTo: parent.assignedTo,
|
|
3535
3709
|
estStarted: localToday(),
|
|
3536
|
-
deadline
|
|
3710
|
+
deadline,
|
|
3537
3711
|
subs,
|
|
3538
3712
|
});
|
|
3713
|
+
Loading$1.succeed(`子任务 ${subs.length} 个创建成功`);
|
|
3539
3714
|
console.log(
|
|
3540
3715
|
chalk.green(
|
|
3541
3716
|
`子任务 ${subs.length} 个已创建(父任务 #${parent.id}): ${subs
|
|
@@ -3547,13 +3722,307 @@ async function storyMain(action, cliOpts) {
|
|
|
3547
3722
|
});
|
|
3548
3723
|
return;
|
|
3549
3724
|
}
|
|
3725
|
+
if (action === "close") {
|
|
3726
|
+
// 入参 = 项目ID(完整url/相对路径/纯ID), 如 project-task-2915-myinvolved.html 里的 2915
|
|
3727
|
+
const input = String(cliOpts.name || "").trim();
|
|
3728
|
+
const match =
|
|
3729
|
+
input.match(/project-task-(\d+)/) ||
|
|
3730
|
+
(/^\d+$/.test(input) ? [input, input] : null);
|
|
3731
|
+
if (!match) {
|
|
3732
|
+
console.log(
|
|
3733
|
+
chalk.red(
|
|
3734
|
+
"请传入项目-任务-由我参与url的ID, 如: fe-it-beta story close 2915 或 fe-it-beta story close https://zentao.hongxinshop.com/zentao/project-task-2915-myinvolved.html"
|
|
3735
|
+
)
|
|
3736
|
+
);
|
|
3737
|
+
process.exit(1);
|
|
3738
|
+
}
|
|
3739
|
+
const projectID = match[1];
|
|
3740
|
+
const session = await ensureZentaoSession(cliOpts);
|
|
3741
|
+
await session.request(async (zentaosid) => {
|
|
3742
|
+
// 项目存在性预校验(不存在的项目ID禅道会返回登录页HTML, 会被误判为会话失效)
|
|
3743
|
+
Loading$1.start("校验项目中...");
|
|
3744
|
+
const projectMap = await fetchProjectMap(zentaosid);
|
|
3745
|
+
if (!projectMap[projectID]) {
|
|
3746
|
+
Loading$1.fail("项目不存在");
|
|
3747
|
+
throw new Error(`项目ID ${projectID} 不存在, 请检查后重试`);
|
|
3748
|
+
}
|
|
3749
|
+
Loading$1.succeed(`项目: #${projectID} ${projectMap[projectID]}`);
|
|
3750
|
+
const account = readZentaoCache().account;
|
|
3751
|
+
// 任务来源(两路取并集):
|
|
3752
|
+
// 1) myinvolved: 指派给登录账号的任务(完成的主入口)
|
|
3753
|
+
// 2) 项目任务列表中"由我完成未关闭"的——完成任务后禅道把指派转回创建人, 会从
|
|
3754
|
+
// myinvolved消失, 但仍需关闭, 须从项目任务列表按 finishedBy 兜回来
|
|
3755
|
+
Loading$1.start("拉取我的任务中...");
|
|
3756
|
+
const involved = await fetchMyInvolvedTasks(zentaosid, projectID);
|
|
3757
|
+
// 项目任务列表(由我完成的任务兜底来源); 失败降级不阻塞(少一路来源), 提示延后到spinner结束统一输出
|
|
3758
|
+
Loading$1.text = "拉取项目任务列表中...";
|
|
3759
|
+
let projectAll = { tasks: [], pageTotal: 1 };
|
|
3760
|
+
let projectAllError = null;
|
|
3761
|
+
try {
|
|
3762
|
+
projectAll = await fetchProjectTasks(zentaosid, projectID);
|
|
3763
|
+
} catch (e) {
|
|
3764
|
+
// 会话失效上抛, 由 ensureZentaoSession 整体重登重试; 其余降级不阻塞(少一路来源)
|
|
3765
|
+
if (e?.isAuthError) throw e;
|
|
3766
|
+
projectAllError = e;
|
|
3767
|
+
}
|
|
3768
|
+
const seen = new Set();
|
|
3769
|
+
const tasks = [];
|
|
3770
|
+
// 指派给我的(过滤已关闭/已取消)
|
|
3771
|
+
involved.tasks
|
|
3772
|
+
.filter(
|
|
3773
|
+
(t) =>
|
|
3774
|
+
t.assignedTo === account && !["closed", "cancel"].includes(t.status)
|
|
3775
|
+
)
|
|
3776
|
+
.forEach((t) => {
|
|
3777
|
+
if (!seen.has(String(t.id))) {
|
|
3778
|
+
seen.add(String(t.id));
|
|
3779
|
+
tasks.push(t);
|
|
3780
|
+
}
|
|
3781
|
+
});
|
|
3782
|
+
// 由我完成的(完成后禅道把指派置'closed'离开myinvolved, 统一从项目任务列表按 finishedBy 兜回):
|
|
3783
|
+
// 已完成待关闭的可勾选关闭; 已关闭的也进列表仅展示(不可选)
|
|
3784
|
+
projectAll.tasks
|
|
3785
|
+
.filter((t) => t.finishedBy === account && t.status !== "cancel")
|
|
3786
|
+
.forEach((t) => {
|
|
3787
|
+
if (!seen.has(String(t.id))) {
|
|
3788
|
+
seen.add(String(t.id));
|
|
3789
|
+
tasks.push(t);
|
|
3790
|
+
}
|
|
3791
|
+
});
|
|
3792
|
+
// 已完成待关闭的排最前(优先处理), 未完成的其后, 已关闭的最后(仅展示不可选), 组内按任务ID升序
|
|
3793
|
+
const statusRank = (t) =>
|
|
3794
|
+
t.status === "done" ? 0 : t.status === "closed" ? 2 : 1;
|
|
3795
|
+
tasks.sort(
|
|
3796
|
+
(a, b) => statusRank(a) - statusRank(b) || Number(a.id) - Number(b.id)
|
|
3797
|
+
);
|
|
3798
|
+
const donePending = tasks.filter((t) => t.status === "done").length;
|
|
3799
|
+
const closedShown = tasks.filter((t) => t.status === "closed").length;
|
|
3800
|
+
Loading$1.succeed(
|
|
3801
|
+
`拉取成功(未完成 ${tasks.length - donePending - closedShown} 条, 待关闭 ${donePending} 条${
|
|
3802
|
+
closedShown ? `, 已关闭 ${closedShown} 条(仅展示)` : ""
|
|
3803
|
+
})`
|
|
3804
|
+
);
|
|
3805
|
+
if (projectAllError) {
|
|
3806
|
+
console.log(
|
|
3807
|
+
chalk.yellow(
|
|
3808
|
+
`项目任务列表拉取失败, 由我完成的任务将不显示: ${projectAllError.message || String(projectAllError)}`
|
|
3809
|
+
)
|
|
3810
|
+
);
|
|
3811
|
+
}
|
|
3812
|
+
// 项目任务超单页时"由我完成"部分可能漏列(禅道翻页路由不稳定, 只提示不翻页)
|
|
3813
|
+
if (projectAll.pageTotal > 1) {
|
|
3814
|
+
console.log(
|
|
3815
|
+
chalk.yellow(
|
|
3816
|
+
`项目任务超过单页(${projectAll.pageTotal}页), 由我完成待关闭的任务可能漏列`
|
|
3817
|
+
)
|
|
3818
|
+
);
|
|
3819
|
+
}
|
|
3820
|
+
if (!tasks.length) {
|
|
3821
|
+
console.log(
|
|
3822
|
+
chalk.yellow("无指派给我的任务, 也无由我完成待关闭的任务, 结束")
|
|
3823
|
+
);
|
|
3824
|
+
return;
|
|
3825
|
+
}
|
|
3826
|
+
// 逐任务拉详情拿子任务(父任务的子任务不出现在任务列表, 展示与关闭都用)
|
|
3827
|
+
const details = new Map(); // 任务ID -> 任务详情
|
|
3828
|
+
const detailTargets = tasks.filter((t) => t.status !== "closed");
|
|
3829
|
+
if (detailTargets.length) {
|
|
3830
|
+
Loading$1.start(`拉取任务详情 (0/${detailTargets.length})...`);
|
|
3831
|
+
}
|
|
3832
|
+
let detailDone = 0;
|
|
3833
|
+
let index = 0;
|
|
3834
|
+
const detailWorker = async () => {
|
|
3835
|
+
while (index < tasks.length) {
|
|
3836
|
+
const t = tasks[index];
|
|
3837
|
+
index += 1;
|
|
3838
|
+
// 已关闭的仅展示不可选, 不参与处理, 跳过详情拉取省请求
|
|
3839
|
+
if (t.status === "closed") continue;
|
|
3840
|
+
try {
|
|
3841
|
+
details.set(String(t.id), await fetchTaskDetail(zentaosid, t.id));
|
|
3842
|
+
} catch (e) {
|
|
3843
|
+
if (e?.isAuthError) throw e;
|
|
3844
|
+
// 详情拉取失败不阻塞(该项关闭时再拉, 展示少了子任务数而已)
|
|
3845
|
+
}
|
|
3846
|
+
detailDone += 1;
|
|
3847
|
+
// ora 的 text 是属性而非方法
|
|
3848
|
+
Loading$1.text = `拉取任务详情 (${detailDone}/${detailTargets.length})...`;
|
|
3849
|
+
}
|
|
3850
|
+
};
|
|
3851
|
+
await Promise.all(
|
|
3852
|
+
Array.from({ length: PULL_CONCURRENCY }, () => detailWorker())
|
|
3853
|
+
);
|
|
3854
|
+
if (detailTargets.length) {
|
|
3855
|
+
Loading$1.succeed("拉取任务详情完成");
|
|
3856
|
+
}
|
|
3857
|
+
// 勾选要完成的任务(完成后自动关闭; 到列表边界停止, 不循环回第一条)
|
|
3858
|
+
const { picked } = await inquirer.prompt([
|
|
3859
|
+
{
|
|
3860
|
+
message: "请勾选要处理的任务(空格选择, 回车确认; 未完成的先完成后关闭, 已完成的直接关闭, 已关闭的仅展示不可选)",
|
|
3861
|
+
name: "picked",
|
|
3862
|
+
type: "checkbox",
|
|
3863
|
+
loop: false,
|
|
3864
|
+
choices: tasks.map((t) => {
|
|
3865
|
+
const detail = details.get(String(t.id));
|
|
3866
|
+
const children = Object.values(detail?.children || {});
|
|
3867
|
+
const closedCount = children.filter(
|
|
3868
|
+
(c) => c.status === "closed"
|
|
3869
|
+
).length;
|
|
3870
|
+
// 第二行详情(子任务进度): 换行 + 缩进对齐, 与标题行区分
|
|
3871
|
+
const childInfo = children.length
|
|
3872
|
+
? `\n 子任务${children.length}个(已关${closedCount}), 关闭全部子任务后父任务自动关闭`
|
|
3873
|
+
: "";
|
|
3874
|
+
return {
|
|
3875
|
+
name: `#${t.id} [${TASK_STATUS_LABELS[t.status] || t.status}] ${t.name} (${t.estimate || 0}h)${childInfo}`,
|
|
3876
|
+
value: t,
|
|
3877
|
+
// 已关闭的仅展示, 不可勾选
|
|
3878
|
+
disabled: t.status === "closed" ? "已关闭" : false,
|
|
3879
|
+
};
|
|
3880
|
+
}),
|
|
3881
|
+
},
|
|
3882
|
+
]);
|
|
3883
|
+
if (!picked.length) {
|
|
3884
|
+
console.log(chalk.yellow("未勾选任务, 结束"));
|
|
3885
|
+
return;
|
|
3886
|
+
}
|
|
3887
|
+
// 写操作确认(关闭属敏感操作, 二次确认防误关)
|
|
3888
|
+
const { ok } = await inquirer.prompt([
|
|
3889
|
+
{
|
|
3890
|
+
message: `确认处理勾选的 ${picked.length} 个任务? (未完成的先完成后关闭; 有子任务的逐个关闭子任务, 全关后父任务自动关闭)`,
|
|
3891
|
+
name: "ok",
|
|
3892
|
+
type: "confirm",
|
|
3893
|
+
default: true,
|
|
3894
|
+
},
|
|
3895
|
+
]);
|
|
3896
|
+
if (!ok) {
|
|
3897
|
+
console.log(chalk.yellow("已取消"));
|
|
3898
|
+
return;
|
|
3899
|
+
}
|
|
3900
|
+
// 单任务完成并关闭(叶子任务): 已 done 直接关, 否则先 finish 再 close
|
|
3901
|
+
// 结果进 logs(执行期间只显示spinner进度, 结束后统一输出, 避免与spinner交错)
|
|
3902
|
+
const finishAndClose = async (task, logs) => {
|
|
3903
|
+
if (task.status === "done") {
|
|
3904
|
+
await closeZentaoTask({ zentaosid, taskID: task.id });
|
|
3905
|
+
logs.push(
|
|
3906
|
+
chalk.green(`任务 #${task.id} ${task.name} 已关闭(原状态已完成)`)
|
|
3907
|
+
);
|
|
3908
|
+
return;
|
|
3909
|
+
}
|
|
3910
|
+
// 本次消耗取任务预估(currentConsumed 为增量, 服务端自动累计总消耗)
|
|
3911
|
+
await finishZentaoTask({
|
|
3912
|
+
zentaosid,
|
|
3913
|
+
taskID: task.id,
|
|
3914
|
+
consumed: task.estimate,
|
|
3915
|
+
});
|
|
3916
|
+
await closeZentaoTask({ zentaosid, taskID: task.id });
|
|
3917
|
+
logs.push(chalk.green(`任务 #${task.id} ${task.name} 已完成并关闭`));
|
|
3918
|
+
};
|
|
3919
|
+
let failedCount = 0;
|
|
3920
|
+
let done = 0;
|
|
3921
|
+
const logs = [];
|
|
3922
|
+
Loading$1.start(`处理任务 (0/${picked.length})...`);
|
|
3923
|
+
for (const task of picked) {
|
|
3924
|
+
try {
|
|
3925
|
+
// 详情缺失(预拉阶段失败的)现拉, 防父任务被误判成叶子任务直接 finish
|
|
3926
|
+
const detail =
|
|
3927
|
+
details.get(String(task.id)) ||
|
|
3928
|
+
(await fetchTaskDetail(zentaosid, task.id));
|
|
3929
|
+
const children = Object.values(detail?.children || {});
|
|
3930
|
+
if (children.length) {
|
|
3931
|
+
// 父任务: 逐个完成并关闭子任务, 全关后禅道自动关闭父任务
|
|
3932
|
+
for (const child of children) {
|
|
3933
|
+
if (child.status === "closed") {
|
|
3934
|
+
logs.push(
|
|
3935
|
+
chalk.yellow(
|
|
3936
|
+
` 子任务 #${child.id} ${child.name} 已是关闭状态, 跳过`
|
|
3937
|
+
)
|
|
3938
|
+
);
|
|
3939
|
+
continue;
|
|
3940
|
+
}
|
|
3941
|
+
if (child.status === "done") {
|
|
3942
|
+
await closeZentaoTask({ zentaosid, taskID: child.id });
|
|
3943
|
+
logs.push(
|
|
3944
|
+
chalk.green(
|
|
3945
|
+
` 子任务 #${child.id} ${child.name} 已关闭(原状态已完成)`
|
|
3946
|
+
)
|
|
3947
|
+
);
|
|
3948
|
+
} else {
|
|
3949
|
+
await finishZentaoTask({
|
|
3950
|
+
zentaosid,
|
|
3951
|
+
taskID: child.id,
|
|
3952
|
+
consumed: child.estimate,
|
|
3953
|
+
});
|
|
3954
|
+
await closeZentaoTask({ zentaosid, taskID: child.id });
|
|
3955
|
+
logs.push(
|
|
3956
|
+
chalk.green(` 子任务 #${child.id} ${child.name} 已完成并关闭`)
|
|
3957
|
+
);
|
|
3958
|
+
}
|
|
3959
|
+
}
|
|
3960
|
+
// 核验父任务最终状态(子任务全关后禅道自动关父任务)
|
|
3961
|
+
const after = await fetchTaskDetail(zentaosid, task.id);
|
|
3962
|
+
if (after?.status === "closed") {
|
|
3963
|
+
logs.push(
|
|
3964
|
+
chalk.green(
|
|
3965
|
+
`父任务 #${task.id} ${task.name} 已自动关闭(子任务全部关闭)`
|
|
3966
|
+
)
|
|
3967
|
+
);
|
|
3968
|
+
} else {
|
|
3969
|
+
logs.push(
|
|
3970
|
+
chalk.yellow(
|
|
3971
|
+
`父任务 #${task.id} ${task.name} 状态 ${after?.status || "?"}, 未自动关闭, 请到禅道手动处理: ${taskUrl(task.id)}`
|
|
3972
|
+
)
|
|
3973
|
+
);
|
|
3974
|
+
}
|
|
3975
|
+
} else {
|
|
3976
|
+
// 叶子任务: 直接完成并关闭
|
|
3977
|
+
await finishAndClose(task, logs);
|
|
3978
|
+
}
|
|
3979
|
+
} catch (e) {
|
|
3980
|
+
// 会话失效上抛, 由 ensureZentaoSession 整体重登重试
|
|
3981
|
+
if (e?.isAuthError) throw e;
|
|
3982
|
+
failedCount += 1;
|
|
3983
|
+
logs.push(
|
|
3984
|
+
chalk.yellow(`任务 #${task.id} ${task.name} 处理失败: ${e.message || String(e)}`)
|
|
3985
|
+
);
|
|
3986
|
+
}
|
|
3987
|
+
done += 1;
|
|
3988
|
+
// ora 的 text 是属性而非方法
|
|
3989
|
+
Loading$1.text = `处理任务 (${done}/${picked.length})...`;
|
|
3990
|
+
}
|
|
3991
|
+
Loading$1.succeed(
|
|
3992
|
+
`处理完成: 成功 ${picked.length - failedCount} 条 / 失败 ${failedCount} 条`
|
|
3993
|
+
);
|
|
3994
|
+
logs.forEach((line) => console.log(line));
|
|
3995
|
+
// 部分失败以非零退出码收尾, 供自动化流程感知重试
|
|
3996
|
+
if (failedCount) {
|
|
3997
|
+
console.log(chalk.red(`失败${failedCount}个, 详情见上方黄字提示`));
|
|
3998
|
+
process.exit(1);
|
|
3999
|
+
}
|
|
4000
|
+
});
|
|
4001
|
+
return;
|
|
4002
|
+
}
|
|
3550
4003
|
// plan: 产品选择 -> 计划选取 -> 展示 -> 导出(--export直通一键场景, 不传则收尾交互)
|
|
3551
4004
|
const session = await ensureZentaoSession(cliOpts);
|
|
3552
4005
|
await session.request(async (zentaosid) => {
|
|
3553
|
-
|
|
4006
|
+
// 位置参数纯数字 = 产品计划ID(productplan-browse-<ID>.html 中的ID), 等同 --product 直通
|
|
4007
|
+
const nameArg = String(cliOpts.name || "").trim();
|
|
4008
|
+
const productIdArg = /^\d+$/.test(nameArg) ? nameArg : "";
|
|
4009
|
+
const product = await selectProduct(zentaosid, {
|
|
4010
|
+
...cliOpts,
|
|
4011
|
+
product: cliOpts.product || productIdArg,
|
|
4012
|
+
});
|
|
3554
4013
|
const plans = await loadPlanList(zentaosid, product.id);
|
|
3555
|
-
|
|
3556
|
-
|
|
4014
|
+
// 该产品无计划时后续选取/详情/导出均无意义, 提前结束
|
|
4015
|
+
if (!plans.length) {
|
|
4016
|
+
console.log(chalk.yellow(`产品 #${product.id} ${product.name} 下无计划, 结束`));
|
|
4017
|
+
return;
|
|
4018
|
+
}
|
|
4019
|
+
// 纯数字已用作产品ID, 非数字关键词仍按计划标题过滤
|
|
4020
|
+
const selected = await selectPlan(plans, productIdArg ? "" : cliOpts.name);
|
|
4021
|
+
const stories = await showPlanDetail(zentaosid, selected);
|
|
4022
|
+
// 空计划提示已在showPlanDetail输出, 直接结束不进导出询问
|
|
4023
|
+
if (!stories.length) {
|
|
4024
|
+
return;
|
|
4025
|
+
}
|
|
3557
4026
|
// 导出范围: --export参数直通(一键场景) > 收尾交互(默认前端)
|
|
3558
4027
|
let exportChoice;
|
|
3559
4028
|
if (cliOpts.export !== undefined) {
|
|
@@ -3562,14 +4031,14 @@ async function storyMain(action, cliOpts) {
|
|
|
3562
4031
|
} else {
|
|
3563
4032
|
({ exportChoice } = await inquirer.prompt([
|
|
3564
4033
|
{
|
|
3565
|
-
message:
|
|
4034
|
+
message: `是否导出需求文件(按计划生成 .zentao-<计划名>目录, ${chalk.red("目前导出功能只适用于claude code批量建任务")})?`,
|
|
3566
4035
|
name: "exportChoice",
|
|
3567
4036
|
type: "list",
|
|
3568
4037
|
choices: [
|
|
3569
|
-
{ name: "
|
|
4038
|
+
{ name: "否", value: "none" }, // 默认第一项
|
|
4039
|
+
{ name: "导出: 前端需求", value: "front" },
|
|
3570
4040
|
{ name: "导出: 后端需求", value: "back" },
|
|
3571
4041
|
{ name: "导出: 全部需求", value: "all" },
|
|
3572
|
-
{ name: "否", value: "none" },
|
|
3573
4042
|
],
|
|
3574
4043
|
},
|
|
3575
4044
|
]));
|
|
@@ -3589,13 +4058,13 @@ function registerStoryCommand(program, { markHandled, addHelpExample }) {
|
|
|
3589
4058
|
program
|
|
3590
4059
|
.command("story <action> [name]")
|
|
3591
4060
|
.description(
|
|
3592
|
-
"禅道需求: login 登录 | plan 选计划看需求列表并可导出(按计划生成需求目录+工作区) | detail 看需求详情 | task 勾选项目需求在线建任务 | subtask 给已建任务单独拆子任务"
|
|
4061
|
+
"禅道需求: login 登录 | plan 选计划看需求列表并可导出(按计划生成需求目录+工作区) | detail 看需求详情 | task 勾选项目需求在线建任务 | subtask 给已建任务单独拆子任务 | close 完成并关闭指派给我的任务"
|
|
3593
4062
|
)
|
|
3594
4063
|
// 子命令的 option 独立于 program 级: story login --cache 中 --cache 出现在子命令位, 须在子命令上重复注册
|
|
3595
4064
|
.option("--username <name>", "禅道账号(story命令用)")
|
|
3596
4065
|
.option("--password <pwd>", "禅道密码(story命令用, 日常建议用--cache)")
|
|
3597
4066
|
.option("--cache", "凭据静默走缓存")
|
|
3598
|
-
.option("--product <id>", "
|
|
4067
|
+
.option("--product <id>", "禅道产品计划ID(plan用, 即productplan-browse-<ID>.html中的ID, 不传则交互选择)")
|
|
3599
4068
|
.option("--export <scope>", "plan导出范围: front|back|all(传了跳过收尾询问直接导出)")
|
|
3600
4069
|
.option("--type <t>", "task任务类型: front|back(传了跳过类型询问与确认, 一键创建)")
|
|
3601
4070
|
.option("--split", "task时创建父任务并自动拆分子任务(>8h, 每个8h末个补余); subtask时跳过确认直接拆")
|
|
@@ -3614,6 +4083,7 @@ function registerStoryCommand(program, { markHandled, addHelpExample }) {
|
|
|
3614
4083
|
// help示例经 helpers 注入 index.js 的 helpExamples, 自动参与列对齐
|
|
3615
4084
|
addHelpExample("fe-it-beta story login", "禅道登录, 缓存zentaosid");
|
|
3616
4085
|
addHelpExample("fe-it-beta story plan --product 87", "选计划看列表, 交互选导出");
|
|
4086
|
+
addHelpExample("fe-it-beta story plan 29", "29=产品计划ID(productplan-browse-29.html), 拉计划列表再选取");
|
|
3617
4087
|
addHelpExample("fe-it-beta story detail 需求ID", "按需求ID查看详情");
|
|
3618
4088
|
addHelpExample("fe-it-beta story task 项目ID", "选项目勾选需求, 交互建禅道任务");
|
|
3619
4089
|
addHelpExample(
|
|
@@ -3621,6 +4091,10 @@ function registerStoryCommand(program, { markHandled, addHelpExample }) {
|
|
|
3621
4091
|
"一键创建前端任务并自动拆分子任务"
|
|
3622
4092
|
);
|
|
3623
4093
|
addHelpExample("fe-it-beta story subtask 父任务ID", "给已建任务单独拆子任务(按剩余工时)");
|
|
4094
|
+
addHelpExample(
|
|
4095
|
+
"fe-it-beta story close 2915",
|
|
4096
|
+
"完成并关闭项目中指派给我的任务(子任务全关父任务自动关闭)"
|
|
4097
|
+
);
|
|
3624
4098
|
addHelpExample(
|
|
3625
4099
|
"fe-it-beta story plan --product 87 --export front --cache",
|
|
3626
4100
|
"一键导出前端需求并生成多项目工作区"
|