Files
tea-sdlc/scripts/schedule.js
T
jiantw83andClaude Opus 5 e5b247e4cf fix(排程): 拒絕重複的工作包 index
同一個 index 出現兩次時,相依看的是後者、標題與人天卻取到前者,算出來的時程是
兩份定義混出來的東西,而且回傳 ok:true 完全不報錯。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 06:06:09 +00:00

139 lines
4.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 依相依關係推算每顆工作包的截止日。
*
* 保證一件事:任一工作包的截止日都不早於它的先決工作包。人工排時程最常出現的
* 矛盾就是前置工作比後續還晚到期,看板上看起來合理、實際上做不到。
*
* 這支腳本不碰 Gitea 也不碰 git,純算數字,所以沒有前置檢查、也不需要登入。
* 算出來的日期交給 issue-update 逐顆寫上去。
*
* 日期以「日曆日」累加,不跳週末也不扣假日——跳過哪些日子是團隊政策,
* 這裡不替使用者決定。
*
* 用法:node scripts/schedule.js --plan-file <計畫檔>
*
* 計畫檔格式:
* {
* "startDate": "2026-09-21",
* "workPackages": [
* { "index": 12, "title": "建立抽取契約", "days": 3, "depends": [11] }
* ]
* }
*/
import { existsSync, readFileSync } from 'node:fs';
import { ScriptError, main, parseFlags } from './lib.js';
main(async () => {
const flags = parseFlags(process.argv.slice(2), { required: ['plan-file'] });
const plan = readPlan(flags['plan-file']);
const order = topologicalOrder(plan.workPackages);
const dueByIndex = new Map();
const schedule = [];
for (const index of order) {
const wp = plan.workPackages.find((item) => item.index === index);
// 從所有先決裡最晚的那一個接著做;沒有先決就從起始日開始
const readyFrom = (wp.depends ?? []).reduce(
(latest, dep) => (dueByIndex.get(dep) > latest ? dueByIndex.get(dep) : latest),
plan.startDate,
);
const dueDate = addDays(readyFrom, wp.days);
dueByIndex.set(index, dueDate);
schedule.push({ index, title: wp.title, days: wp.days, dueDate });
}
return { startDate: plan.startDate, order, schedule };
});
function readPlan(planFile) {
if (!existsSync(planFile)) {
throw new ScriptError('PLAN_FILE_MISSING', `找不到 --plan-file 指定的檔案 ${planFile}`);
}
let plan;
try {
plan = JSON.parse(readFileSync(planFile, 'utf8'));
} catch (error) {
throw new ScriptError('BAD_PLAN', `${planFile} 不是合法的 JSON:${error.message}`);
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(plan.startDate ?? '')) {
throw new ScriptError('BAD_PLAN', `startDate 需為 YYYY-MM-DD,收到的是 ${plan.startDate}`);
}
if (!Array.isArray(plan.workPackages) || plan.workPackages.length === 0) {
throw new ScriptError('BAD_PLAN', 'workPackages 需為非空陣列');
}
const seen = new Set();
for (const wp of plan.workPackages) {
// 同一個 index 出現兩次時,相依看的是後者、標題與人天卻取到前者,
// 算出來的時程會是兩份定義混出來的東西,而且完全不會報錯
if (seen.has(wp.index)) {
throw new ScriptError('BAD_PLAN', `工作包 #${wp.index} 在計畫裡出現了不只一次`);
}
seen.add(wp.index);
if (!Number.isInteger(wp.index) || wp.index <= 0) {
throw new ScriptError('BAD_PLAN', `工作包的 index 需為正整數,收到的是 ${wp.index}`);
}
if (!Number.isFinite(wp.days) || wp.days <= 0) {
throw new ScriptError('BAD_PLAN', `工作包 #${wp.index} 的 days 需為正數,收到的是 ${wp.days}`);
}
}
return plan;
}
/**
* 拓撲排序:先決一定排在後續之前。
* 用 Kahn 演算法——排不完就代表有環,而環上的成員正是排不進去的那些。
*/
function topologicalOrder(workPackages) {
const known = new Set(workPackages.map((wp) => wp.index));
const remaining = new Map();
for (const wp of workPackages) {
for (const dep of wp.depends ?? []) {
if (!known.has(dep)) {
throw new ScriptError(
'UNKNOWN_DEPENDENCY',
`工作包 #${wp.index} 的先決 #${dep} 不在這份計畫裡`,
);
}
}
remaining.set(wp.index, new Set(wp.depends ?? []));
}
const order = [];
while (remaining.size > 0) {
const ready = [...remaining.entries()]
.filter(([, deps]) => deps.size === 0)
.map(([index]) => index)
.sort((a, b) => a - b);
if (ready.length === 0) {
throw new ScriptError(
'CYCLE_DETECTED',
`相依關係成環,這些工作包彼此卡住:${[...remaining.keys()].sort((a, b) => a - b).join('、')}`,
);
}
for (const index of ready) {
order.push(index);
remaining.delete(index);
}
for (const deps of remaining.values()) {
for (const index of ready) deps.delete(index);
}
}
return order;
}
/** 在 YYYY-MM-DD 上加幾個日曆日,回傳同樣格式 */
function addDays(date, days) {
const moment = new Date(`${date}T00:00:00Z`);
moment.setUTCDate(moment.getUTCDate() + days);
return moment.toISOString().slice(0, 10);
}