#!/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 需為非空陣列'); } for (const wp of plan.workPackages) { 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); }