feat/wp-schedule-and-board/main #31
+67
-5
@@ -1,12 +1,13 @@
|
||||
name: sdlc-analyze
|
||||
description: 僅由 /sdlc-analyze 指令叫用。對一顆需求議題執行可行性檢查,逐題問到共識後產生工作包議題。
|
||||
description: 僅由 /sdlc-analyze 指令叫用。對一顆需求議題執行可行性檢查,逐題問到共識後產生工作包議題並排上時程。
|
||||
|
||||
# sdlc-analyze
|
||||
|
||||
對一顆需求議題執行可行性檢查,把疑點一題一題問到雙方有共識,再把共識變成一批工作包議題。
|
||||
|
||||
分成兩段:**可行性分析**到共識摘要為止,完全不寫入 Gitea;使用者看過摘要點頭之後,
|
||||
才進入**產生工作包**,那一段才會建立議題。
|
||||
分成三段:**可行性分析**到共識摘要為止,完全不寫入 Gitea;使用者看過摘要點頭之後,
|
||||
才進入**產生工作包**建立議題;最後**排上時程與看板**,把相依、截止日、Milestone、
|
||||
看板與人天估算補上。
|
||||
|
||||
這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。
|
||||
|
||||
@@ -128,6 +129,67 @@ no-op」。確認無誤後拿掉該旗標再跑一次。
|
||||
|
||||
列出每顆工作包的編號、標題與網址。不要把議題內容再貼一次。
|
||||
|
||||
## 第三段:排上時程與看板
|
||||
|
||||
工作包建好之後,把它們之間的關係與時程補上。做完這一段,看板上呈現的才是真實的
|
||||
開發順序,而不是一堆平鋪的議題。
|
||||
|
||||
### 9. 算出截止日
|
||||
|
||||
把每顆工作包的編號、人天估算與先決關係寫成一份計畫檔:
|
||||
|
||||
```json
|
||||
{
|
||||
"startDate": "2026-09-21",
|
||||
"workPackages": [
|
||||
{ "index": 12, "title": "建立共用函式庫", "days": 3 },
|
||||
{ "index": 13, "title": "建立抽取契約", "days": 2, "depends": [12] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
node scripts/schedule.js --plan-file <計畫檔>
|
||||
```
|
||||
|
||||
它依相依關係做拓撲排序,保證**任一工作包的截止日都不早於它的先決**——人工排時程
|
||||
最常見的矛盾就是前置工作比後續還晚到期。相依成環時它會直接報錯並指出環上的成員,
|
||||
那代表拆法有問題,回頭改拆法,不要硬排。
|
||||
|
||||
日期以日曆日累加,不跳週末也不扣假日。要跳的話自己把 `startDate` 或人天調整過再算。
|
||||
|
||||
### 10. 逐顆補上關係與時程
|
||||
|
||||
對每一顆工作包,依序:
|
||||
|
||||
```
|
||||
node scripts/issue-link.js --repo <owner/name> --index <編號> --depends <先決編號清單>
|
||||
node scripts/issue-update.js --repo <owner/name> --index <編號> \
|
||||
--milestone "<既有 Milestone 名稱>" --due-date <schedule 算出的日期> --estimate-days <人天>
|
||||
node scripts/project-add.js --repo <owner/name> --index <編號> --project "<看板名稱或網址>"
|
||||
```
|
||||
|
||||
三支都先用 `--dry-run` 看過再實跑。三支都是冪等的:相依已存在就跳過、已在看板上就不重發、
|
||||
估算沒變就不改 body。
|
||||
|
||||
**Milestone 與看板都只掛既有的。** 指到不存在的 Milestone 會中止並列出可選項目;
|
||||
看板名稱靠掃最近 50 筆議題反查 id,反查不到就會請你直接貼專案網址(結尾即 id)。
|
||||
本流程不建立 Milestone,也不建立專案。
|
||||
|
||||
### 11. 回報
|
||||
|
||||
列出每顆工作包的編號、標題、截止日與所屬 Milestone,並指出**相依鏈最長路徑**上的那幾顆
|
||||
——那條路徑決定整體交期。
|
||||
|
||||
## 已知限制:人天估算只寫得進 body
|
||||
|
||||
Gitea 1.27 的 API 沒有任何請求定義接受 `time_estimate`,該欄位只出現在議題的回應裡。
|
||||
也就是說**議題的估算欄位無法由 API 寫入**,只能靠人在網頁上填。
|
||||
|
||||
因此 `issue-update --estimate-days` 只把估算寫成議題 body 裡人類可讀的一行
|
||||
(`估算人天:N`,放在「關聯」段落)。之後 `sdlc-report` 要比對估算與實際工時時,
|
||||
讀的也是這一行。
|
||||
|
||||
## 架構圖的限制
|
||||
|
||||
依工作包的性質選圖:
|
||||
@@ -146,8 +208,8 @@ no-op」。確認無誤後拿掉該旗標再跑一次。
|
||||
## 邊界
|
||||
|
||||
- **共識摘要之前不對 Gitea 產生任何寫入**:不建議題、不改描述、不貼標籤、不留留言。
|
||||
- 第二段只建立工作包議題。不建相依、不掛 Milestone、不加看板、不寫人天估算——那是後續流程的事。
|
||||
- 不自行建立標籤、Milestone 或專案看板。
|
||||
- 第二段只建立工作包議題。不建相依、不掛 Milestone、不加看板、不寫人天估算——那是第三段的事。
|
||||
- 第三段只掛既有的 Milestone 與看板。不自行建立標籤、Milestone 或專案看板。
|
||||
- 不修改使用者的專案檔案。查證既有功能時只讀不寫。
|
||||
- 不替使用者決定他沒回答的事。問不到答案就進「仍然未決的事」。
|
||||
- 不關閉或刪除任何既有議題。
|
||||
|
||||
@@ -141,3 +141,62 @@ function splitRow(line) {
|
||||
function isSeparator(cells) {
|
||||
return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell));
|
||||
}
|
||||
|
||||
/**
|
||||
* 在指定段落裡就地更新(或補上)一行「前綴+值」。
|
||||
*
|
||||
* 用於人天估算這種「議題上只該有一行、重跑要覆蓋而不是累積」的欄位。
|
||||
*
|
||||
* 三件事都限定在目標段落之內,因為弄錯的代價是靜靜改壞別人的內容:
|
||||
* - 標題要完全相同才算數,`## 關聯度說明` 不是 `## 關聯`。
|
||||
* - 圍欄裡的假標題不算標題,沿用本檔共同的圍欄判斷。
|
||||
* - 找既有那一行只在段落範圍內找,別的段落剛好有同前綴時不會被改掉。
|
||||
*
|
||||
* 段落不存在時補在 body 結尾——寧可放錯位置,也不要讓值靜靜消失。
|
||||
*
|
||||
* @param {string} body 議題 body
|
||||
* @param {string} section 段落名稱,例如 '關聯'
|
||||
* @param {string} line 完整的一行,例如 '估算人天:3'
|
||||
* @returns {string} 更新後的 body;內容沒有變動時回傳原字串
|
||||
*/
|
||||
export function upsertLineInSection(body, section, line) {
|
||||
const prefix = line.slice(0, line.indexOf(':') + 1);
|
||||
const rows = [...eachLine(body)];
|
||||
const { start, end } = sectionBounds(rows, section);
|
||||
|
||||
if (start === -1) {
|
||||
return `${body.replace(/\n*$/, '')}\n\n${line}\n`;
|
||||
}
|
||||
|
||||
const text = rows.map((row) => row.line);
|
||||
for (let i = start + 1; i < end; i += 1) {
|
||||
if (rows[i].inFence || !text[i].startsWith(prefix)) continue;
|
||||
if (text[i] === line) return body;
|
||||
text[i] = line;
|
||||
return text.join('\n');
|
||||
}
|
||||
|
||||
// 插在段落內容的結尾,跳過段落與段落之間的空行
|
||||
let insertAt = end;
|
||||
while (insertAt > start + 1 && text[insertAt - 1].trim() === '') insertAt -= 1;
|
||||
text.splice(insertAt, 0, line);
|
||||
return text.join('\n');
|
||||
}
|
||||
|
||||
/** 找出段落的起訖行號;start 為標題那一行,end 為下一個標題(或結尾) */
|
||||
function sectionBounds(rows, section) {
|
||||
let start = -1;
|
||||
|
||||
for (let i = 0; i < rows.length; i += 1) {
|
||||
if (rows[i].inFence) continue;
|
||||
const heading = rows[i].line.match(/^##\s+(.+?)\s*$/);
|
||||
if (!heading) continue;
|
||||
|
||||
if (start === -1) {
|
||||
if (heading[1] === section) start = i;
|
||||
continue;
|
||||
}
|
||||
return { start, end: i };
|
||||
}
|
||||
return { start, end: rows.length };
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
main,
|
||||
pages,
|
||||
parseFlags,
|
||||
parseIndex,
|
||||
parseRepo,
|
||||
preflight,
|
||||
resolveLogin,
|
||||
@@ -70,12 +71,6 @@ main(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
function parseIndex(value) {
|
||||
if (!/^[1-9]\d*$/.test(value)) {
|
||||
throw new ScriptError('BAD_INDEX', `--index 需為正整數,收到的是 ${value}`);
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
async function fetchIssue(login, repo, index, path) {
|
||||
const response = await giteaRequest(login, 'GET', path);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 建立議題之間的相依。
|
||||
*
|
||||
* --depends 這顆被誰擋住(它的先決)
|
||||
* --blocks 這顆擋住誰
|
||||
*
|
||||
* 冪等:先讀現況,只補缺的那幾條。重跑不會在 Gitea 上堆出重複的相依。
|
||||
*
|
||||
* `--dry-run` 不寫入但會讀——要列出「將建立哪幾條」就得先知道哪幾條已經存在。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/issue-link.js --repo owner/name --index 12
|
||||
* [--depends 10,11] [--blocks 13] [--host <網址>] [--dry-run]
|
||||
*/
|
||||
import {
|
||||
ScriptError,
|
||||
expectOk,
|
||||
giteaRequest,
|
||||
main,
|
||||
parseFlags,
|
||||
parseIndex,
|
||||
parseRepo,
|
||||
preflight,
|
||||
resolveLogin,
|
||||
} from './lib.js';
|
||||
|
||||
/** 兩種相依各自的端點與意義 */
|
||||
const KINDS = {
|
||||
depends: { path: 'dependencies', label: '先決' },
|
||||
blocks: { path: 'blocks', label: '阻擋' },
|
||||
};
|
||||
|
||||
main(async () => {
|
||||
const flags = parseFlags(process.argv.slice(2), {
|
||||
required: ['repo', 'index'],
|
||||
optional: ['depends', 'blocks', 'host'],
|
||||
booleans: ['dry-run'],
|
||||
});
|
||||
const repo = parseRepo(flags.repo);
|
||||
const [owner, name] = repo.split('/');
|
||||
const index = parseIndex(flags.index, '--index');
|
||||
const wanted = {
|
||||
depends: parseIndexList(flags.depends, index),
|
||||
blocks: parseIndexList(flags.blocks, index),
|
||||
};
|
||||
|
||||
if (wanted.depends.length === 0 && wanted.blocks.length === 0) {
|
||||
throw new ScriptError('NOTHING_TO_LINK', '至少要指定 --depends 或 --blocks 其中一個');
|
||||
}
|
||||
|
||||
const login = resolveLogin({ host: flags.host });
|
||||
if (!flags['dry-run']) await preflight(login, repo);
|
||||
|
||||
const added = {};
|
||||
const skipped = {};
|
||||
const planned = [];
|
||||
|
||||
for (const [kind, { path }] of Object.entries(KINDS)) {
|
||||
const endpoint = `/repos/${repo}/issues/${index}/${path}`;
|
||||
const existing = new Set(
|
||||
(expectOk(await giteaRequest(login, 'GET', endpoint), `GET ${endpoint}`) ?? []).map(
|
||||
(issue) => issue.number,
|
||||
),
|
||||
);
|
||||
|
||||
added[kind] = wanted[kind].filter((target) => !existing.has(target));
|
||||
skipped[kind] = wanted[kind].filter((target) => existing.has(target));
|
||||
|
||||
for (const target of added[kind]) {
|
||||
const body = { owner, repo: name, index: target };
|
||||
if (flags['dry-run']) {
|
||||
planned.push({ method: 'POST', path: endpoint, body });
|
||||
continue;
|
||||
}
|
||||
expectOk(await giteaRequest(login, 'POST', endpoint, { body }), `POST ${endpoint}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (flags['dry-run']) {
|
||||
return { dryRun: true, repo, index, requests: planned, added, skipped };
|
||||
}
|
||||
return { repo, index, added, skipped };
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* 解析逗號分隔的議題編號。
|
||||
* 自己依賴自己在 Gitea 上會被接受但毫無意義,而且會讓後續的拓撲排序永遠排不完,
|
||||
* 所以擋在發出請求之前。
|
||||
*/
|
||||
function parseIndexList(value, self) {
|
||||
if (value === undefined) return [];
|
||||
|
||||
const indexes = value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== '')
|
||||
.map((item) => parseIndex(item, '相依編號'));
|
||||
|
||||
if (indexes.includes(self)) {
|
||||
throw new ScriptError('SELF_DEPENDENCY', `議題 #${self} 不能是自己的先決或阻擋對象`);
|
||||
}
|
||||
return indexes;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 更新議題的時程相關欄位:Milestone、截止日、人天估算。
|
||||
*
|
||||
* 三件事經同一個 PATCH 送出,因為排時程時它們幾乎總是一起改。
|
||||
*
|
||||
* 人天估算只寫進 body 的人類可讀一行。Gitea 1.27 的 API 沒有任何請求定義接受
|
||||
* `time_estimate`——它只出現在 Issue 的回應裡——所以議題的估算欄位無法由 API 寫入。
|
||||
*
|
||||
* Milestone 只認既有的:指到不存在的就中止並列出可選項目,本工具不建立 Milestone。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/issue-update.js --repo owner/name --index 12
|
||||
* [--milestone <名稱>] [--due-date YYYY-MM-DD] [--estimate-days N]
|
||||
* [--host <網址>] [--dry-run]
|
||||
*/
|
||||
import {
|
||||
ScriptError,
|
||||
expectOk,
|
||||
giteaRequest,
|
||||
main,
|
||||
parseFlags,
|
||||
parseIndex,
|
||||
parseRepo,
|
||||
preflight,
|
||||
resolveLogin,
|
||||
} from './lib.js';
|
||||
import { upsertLineInSection } from './issue-body.js';
|
||||
|
||||
main(async () => {
|
||||
const flags = parseFlags(process.argv.slice(2), {
|
||||
required: ['repo', 'index'],
|
||||
optional: ['milestone', 'due-date', 'estimate-days', 'host'],
|
||||
booleans: ['dry-run'],
|
||||
});
|
||||
const repo = parseRepo(flags.repo);
|
||||
const index = parseIndex(flags.index);
|
||||
const dueDate = parseDueDate(flags['due-date']);
|
||||
const days = parseDays(flags['estimate-days']);
|
||||
|
||||
if (flags.milestone === undefined && dueDate === null && days === null) {
|
||||
throw new ScriptError(
|
||||
'NOTHING_TO_UPDATE',
|
||||
'至少要指定 --milestone、--due-date 或 --estimate-days 其中一個',
|
||||
);
|
||||
}
|
||||
|
||||
const login = resolveLogin({ host: flags.host });
|
||||
if (!flags['dry-run']) await preflight(login, repo);
|
||||
|
||||
const path = `/repos/${repo}/issues/${index}`;
|
||||
const payload = {};
|
||||
|
||||
if (flags.milestone !== undefined) {
|
||||
payload.milestone = await resolveMilestoneId(login, repo, flags.milestone);
|
||||
}
|
||||
if (dueDate !== null) {
|
||||
payload.due_date = `${dueDate}T00:00:00Z`;
|
||||
}
|
||||
if (days !== null) {
|
||||
const issue = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`);
|
||||
const updated = upsertLineInSection(issue.body ?? '', '關聯', `估算人天:${days}`);
|
||||
// 沒變就不塞進 PATCH:無謂改寫 body 會在議題上留下一筆沒有內容的編輯紀錄
|
||||
if (updated !== issue.body) payload.body = updated;
|
||||
}
|
||||
|
||||
if (flags['dry-run']) {
|
||||
return { dryRun: true, repo, index, requests: [{ method: 'PATCH', path, body: payload }] };
|
||||
}
|
||||
|
||||
const issue = expectOk(await giteaRequest(login, 'PATCH', path, { body: payload }), `PATCH ${path}`);
|
||||
return {
|
||||
repo,
|
||||
index,
|
||||
updated: Object.keys(payload),
|
||||
url: issue.html_url,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
function parseDueDate(value) {
|
||||
if (value === undefined) return null;
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
throw new ScriptError('BAD_DUE_DATE', `--due-date 需為 YYYY-MM-DD,收到的是 ${value}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseDays(value) {
|
||||
if (value === undefined) return null;
|
||||
const days = Number(value);
|
||||
if (!Number.isFinite(days) || days <= 0) {
|
||||
throw new ScriptError('BAD_ESTIMATE', `--estimate-days 需為正數,收到的是 ${value}`);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
async function resolveMilestoneId(login, repo, title) {
|
||||
const path = `/repos/${repo}/milestones`;
|
||||
const milestones = expectOk(
|
||||
await giteaRequest(login, 'GET', path, { query: { state: 'all' } }),
|
||||
`GET ${path}`,
|
||||
) ?? [];
|
||||
|
||||
const hit = milestones.find((milestone) => milestone.title === title);
|
||||
if (!hit) {
|
||||
throw new ScriptError(
|
||||
'UNKNOWN_MILESTONE',
|
||||
`${repo} 沒有名為「${title}」的 Milestone。本工具不建立 Milestone,` +
|
||||
`請改挑既有的:${milestones.map((m) => m.title).join('、') || '(這個 repo 目前沒有 Milestone)'}`,
|
||||
);
|
||||
}
|
||||
return hit.id;
|
||||
}
|
||||
@@ -88,6 +88,19 @@ export function parseFlags(argv, spec = {}) {
|
||||
return flags;
|
||||
}
|
||||
|
||||
/**
|
||||
* 驗證議題編號。四支腳本都要做這件事,錯誤碼也該一致。
|
||||
* @param {string|number} value
|
||||
* @param {string} what 出現在錯誤訊息裡的欄位名,例如 '--index'
|
||||
* @returns {number}
|
||||
*/
|
||||
export function parseIndex(value, what = '--index') {
|
||||
if (!/^[1-9]\d*$/.test(String(value))) {
|
||||
throw new ScriptError('BAD_INDEX', `${what} 需為正整數,收到的是 ${value}`);
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 驗證並正規化 owner/name 形式的 repo。
|
||||
* @param {string} value
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 把議題放進 Gitea 專案看板。
|
||||
*
|
||||
* Gitea 1.27 沒有「列出專案」的 endpoint,所以 `--project` 給名稱時只能反查:
|
||||
* 掃最近 50 筆議題,從它們身上的 projects 欄位湊出 id→名稱對照。全都沒掛看板時
|
||||
* 就湊不出來,這時請使用者直接貼專案網址(結尾即 id)。
|
||||
*
|
||||
* 本工具不建立專案——API 也沒有這個能力。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/project-add.js --repo owner/name --index 12 --project <名稱或網址>
|
||||
* [--host <網址>] [--dry-run]
|
||||
*/
|
||||
import {
|
||||
ScriptError,
|
||||
expectOk,
|
||||
giteaRequest,
|
||||
main,
|
||||
parseFlags,
|
||||
parseIndex,
|
||||
parseRepo,
|
||||
preflight,
|
||||
resolveLogin,
|
||||
} from './lib.js';
|
||||
|
||||
main(async () => {
|
||||
const flags = parseFlags(process.argv.slice(2), {
|
||||
required: ['repo', 'index', 'project'],
|
||||
optional: ['host'],
|
||||
booleans: ['dry-run'],
|
||||
});
|
||||
const repo = parseRepo(flags.repo);
|
||||
const index = parseIndex(flags.index);
|
||||
const path = `/repos/${repo}/issues/${index}`;
|
||||
|
||||
const login = resolveLogin({ host: flags.host });
|
||||
if (!flags['dry-run']) await preflight(login, repo);
|
||||
|
||||
const project = await resolveProject(login, repo, flags.project);
|
||||
const issue = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`);
|
||||
const current = (issue.projects ?? []).map((item) => item.id);
|
||||
|
||||
if (current.includes(project.id)) {
|
||||
return { repo, index, project, alreadyThere: true };
|
||||
}
|
||||
|
||||
// projects 是整份取代,不是附加:漏掉既有的等於把這顆議題踢出原本的看板
|
||||
const payload = { projects: [...current, project.id] };
|
||||
|
||||
if (flags['dry-run']) {
|
||||
return {
|
||||
dryRun: true,
|
||||
repo,
|
||||
index,
|
||||
project,
|
||||
requests: [{ method: 'PATCH', path, body: payload }],
|
||||
};
|
||||
}
|
||||
|
||||
expectOk(await giteaRequest(login, 'PATCH', path, { body: payload }), `PATCH ${path}`);
|
||||
return { repo, index, project, alreadyThere: false };
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* 把 `--project` 解析成 {id, title}。
|
||||
* 看起來像網址就取結尾的 id;否則掃議題反查名稱。
|
||||
*/
|
||||
async function resolveProject(login, repo, value) {
|
||||
if (/^https?:\/\//.test(value)) {
|
||||
const id = value.replace(/\/+$/, '').match(/(\d+)$/)?.[1];
|
||||
if (!id) {
|
||||
throw new ScriptError('PROJECT_NOT_FOUND', `專案網址的結尾必須是專案 id:${value}`);
|
||||
}
|
||||
return { id: Number(id), title: null };
|
||||
}
|
||||
|
||||
const known = await scanProjects(login, repo);
|
||||
const hit = known.find((project) => project.title === value);
|
||||
if (hit) return hit;
|
||||
|
||||
const seen = known.map((project) => project.title).join('、');
|
||||
throw new ScriptError(
|
||||
'PROJECT_NOT_FOUND',
|
||||
seen
|
||||
? `反查不到名為「${value}」的看板。掃到的有:${seen}。若它不在其中,請直接貼專案網址(結尾即 id)`
|
||||
: `最近的議題沒有任何一顆掛在看板上,無法反查看板 id。請直接貼專案網址(結尾即 id)`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 掃最近 50 筆議題,湊出這個 repo 用過的看板。
|
||||
* Gitea 沒給列出專案的 endpoint,這是唯一拿得到 id→名稱對照的辦法。
|
||||
*/
|
||||
async function scanProjects(login, repo) {
|
||||
const path = `/repos/${repo}/issues`;
|
||||
const issues = expectOk(
|
||||
await giteaRequest(login, 'GET', path, { query: { state: 'all', limit: 50 } }),
|
||||
`GET ${path}`,
|
||||
) ?? [];
|
||||
|
||||
const byId = new Map();
|
||||
for (const issue of issues) {
|
||||
for (const project of issue.projects ?? []) {
|
||||
byId.set(project.id, { id: project.id, title: project.title });
|
||||
}
|
||||
}
|
||||
return [...byId.values()];
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/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);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 議題相依:把工作包之間的阻擋/先決關係建到 Gitea 原生的相依上,
|
||||
* 讓看板呈現真實的開發順序,而不是一堆平鋪的議題。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { runScript } from './helpers/run-script.js';
|
||||
import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
const INDEX = 12;
|
||||
|
||||
/** 預設情境:這顆議題目前沒有任何相依 */
|
||||
function routes(overrides = {}) {
|
||||
return healthyRoutes(REPO, {
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/dependencies`]: { status: 200, body: [] },
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/blocks`]: { status: 200, body: [] },
|
||||
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/dependencies`]: { status: 201, body: {} },
|
||||
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/blocks`]: { status: 201, body: {} },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
const withStub = (t, overrides = {}) => withStubGitea(t, routes(overrides));
|
||||
|
||||
const run = (args, stub) =>
|
||||
runScript('issue-link.js', ['--repo', REPO, '--index', String(INDEX), ...args], {
|
||||
env: envFor(stub),
|
||||
});
|
||||
|
||||
const posts = (stub, kind) =>
|
||||
stub.requests.filter((r) => r.method === 'POST' && r.path.endsWith(`/${kind}`));
|
||||
|
||||
// ── 建立相依 ───────────────────────────────────────────────────────
|
||||
|
||||
test('--depends 建立「這顆被誰擋住」的相依', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run(['--depends', '10,11'], stub);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.ok, true);
|
||||
assert.deepEqual(posts(stub, 'dependencies').map((r) => r.body.index), [10, 11]);
|
||||
});
|
||||
|
||||
test('--blocks 建立「這顆擋住誰」的相依', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
await run(['--blocks', '13'], stub);
|
||||
|
||||
assert.deepEqual(posts(stub, 'blocks').map((r) => r.body.index), [13]);
|
||||
});
|
||||
|
||||
test('請求帶上 owner 與 repo:Gitea 的相依端點少了它們會回 404', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
await run(['--depends', '10'], stub);
|
||||
|
||||
const [post] = posts(stub, 'dependencies');
|
||||
assert.deepEqual(post.body, { owner: 'plugins', repo: 'tea-sdlc', index: 10 });
|
||||
});
|
||||
|
||||
test('兩種相依可以一次建立', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(['--depends', '10', '--blocks', '13'], stub);
|
||||
|
||||
assert.deepEqual(json.data.added, { depends: [10], blocks: [13] });
|
||||
});
|
||||
|
||||
test('回傳這顆議題最後的相依狀態', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(['--depends', '10'], stub);
|
||||
|
||||
assert.equal(json.data.index, INDEX);
|
||||
assert.equal(json.data.repo, REPO);
|
||||
});
|
||||
|
||||
// ── 冪等 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('已經存在的相依不重建', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/dependencies`]: {
|
||||
status: 200,
|
||||
body: [{ number: 10 }],
|
||||
},
|
||||
});
|
||||
|
||||
const { json } = await run(['--depends', '10,11'], stub);
|
||||
|
||||
assert.deepEqual(posts(stub, 'dependencies').map((r) => r.body.index), [11], '只補缺的那一個');
|
||||
assert.deepEqual(json.data.added.depends, [11]);
|
||||
assert.deepEqual(json.data.skipped.depends, [10]);
|
||||
});
|
||||
|
||||
test('全部都已存在時一個請求都不發', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/dependencies`]: {
|
||||
status: 200,
|
||||
body: [{ number: 10 }],
|
||||
},
|
||||
});
|
||||
|
||||
const { code, json } = await run(['--depends', '10'], stub);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.deepEqual(posts(stub, 'dependencies'), []);
|
||||
assert.deepEqual(json.data.added.depends, []);
|
||||
});
|
||||
|
||||
// ── 參數 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('沒給任何相依時就不必問 Gitea', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run([], stub);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'NOTHING_TO_LINK');
|
||||
assert.equal(stub.requests.length, 0);
|
||||
});
|
||||
|
||||
test('相依編號不是正整數時擋在打 Gitea 之前', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(['--depends', '10,abc'], stub);
|
||||
|
||||
assert.equal(json.error.code, 'BAD_INDEX');
|
||||
assert.equal(stub.requests.length, 0);
|
||||
});
|
||||
|
||||
test('不能把議題設成自己的先決', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(['--depends', String(INDEX)], stub);
|
||||
|
||||
assert.equal(json.error.code, 'SELF_DEPENDENCY');
|
||||
assert.equal(stub.requests.length, 0);
|
||||
});
|
||||
|
||||
// ── --dry-run ─────────────────────────────────────────────────────
|
||||
|
||||
test('--dry-run 列出將建立的相依,且不發出任何寫入請求', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/dependencies`]: {
|
||||
status: 200,
|
||||
body: [{ number: 10 }],
|
||||
},
|
||||
});
|
||||
|
||||
const { code, json } = await run(['--depends', '10,11', '--blocks', '13', '--dry-run'], stub);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.dryRun, true);
|
||||
assert.deepEqual(
|
||||
json.data.requests.map((r) => `${r.method} ${r.path} ${r.body.index}`),
|
||||
[
|
||||
`POST /repos/${REPO}/issues/${INDEX}/dependencies 11`,
|
||||
`POST /repos/${REPO}/issues/${INDEX}/blocks 13`,
|
||||
],
|
||||
'已存在的 10 不該出現在預覽裡',
|
||||
);
|
||||
assert.equal(stub.requests.some((r) => r.method !== 'GET'), false);
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* 掛 Milestone、寫截止日、記人天估算。
|
||||
* 三件事都經同一個 PATCH 送出,因為它們常常要一起改,分開發等於多兩次往返。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { runScript } from './helpers/run-script.js';
|
||||
import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
const INDEX = 12;
|
||||
const BODY = '## 這個工作包在做什麼\n\n一句話。\n\n## 關聯\n\n需求議題:#1\n';
|
||||
|
||||
function routes(overrides = {}, { body = BODY } = {}) {
|
||||
return healthyRoutes(REPO, {
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
|
||||
status: 200,
|
||||
body: { number: INDEX, title: '領取工作包並備妥分支', body, html_url: 'https://example.com/12' },
|
||||
},
|
||||
[`GET /api/v1/repos/${REPO}/milestones`]: {
|
||||
status: 200,
|
||||
body: [
|
||||
{ id: 3, title: '第一階段' },
|
||||
{ id: 4, title: '第二階段' },
|
||||
],
|
||||
},
|
||||
[`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`]: (req) => ({
|
||||
status: 200,
|
||||
body: { number: INDEX, ...req.body },
|
||||
}),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options));
|
||||
|
||||
const run = (args, stub) =>
|
||||
runScript('issue-update.js', ['--repo', REPO, '--index', String(INDEX), ...args], {
|
||||
env: envFor(stub),
|
||||
});
|
||||
|
||||
const patchOf = (stub) => stub.requests.find((r) => r.method === 'PATCH' && !r.path.endsWith('/0'));
|
||||
|
||||
// ── Milestone ─────────────────────────────────────────────────────
|
||||
|
||||
test('以名稱指定 Milestone,送出時換成 id', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run(['--milestone', '第二階段'], stub);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
assert.equal(patchOf(stub).body.milestone, 4);
|
||||
});
|
||||
|
||||
test('Milestone 不存在時中止,並列出可選的', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run(['--milestone', '第三階段'], stub);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'UNKNOWN_MILESTONE');
|
||||
assert.match(json.error.message, /第三階段/);
|
||||
assert.match(json.error.message, /第一階段/, '要列出可選的');
|
||||
assert.equal(stub.requests.some((r) => r.method === 'PATCH' && !r.path.endsWith('/0')), false);
|
||||
});
|
||||
|
||||
test('不建立 Milestone:沒有任何請求寫到 milestones 端點', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
await run(['--milestone', '第一階段'], stub);
|
||||
|
||||
const writes = stub.requests.filter((r) => r.path.endsWith('/milestones') && r.method !== 'GET');
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
// ── 截止日 ─────────────────────────────────────────────────────────
|
||||
|
||||
test('截止日以 RFC3339 送出', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
await run(['--due-date', '2026-09-24'], stub);
|
||||
|
||||
assert.equal(patchOf(stub).body.due_date, '2026-09-24T00:00:00Z');
|
||||
});
|
||||
|
||||
test('截止日格式不對時擋在打 Gitea 之前', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(['--due-date', '2026/09/24'], stub);
|
||||
|
||||
assert.equal(json.error.code, 'BAD_DUE_DATE');
|
||||
assert.equal(stub.requests.length, 0);
|
||||
});
|
||||
|
||||
// ── 人天估算 ───────────────────────────────────────────────────────
|
||||
|
||||
test('人天估算寫成議題 body 裡人類可讀的一行', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
await run(['--estimate-days', '3'], stub);
|
||||
|
||||
assert.match(patchOf(stub).body.body, /^估算人天:3$/m);
|
||||
});
|
||||
|
||||
test('估算寫進關聯段落,不是隨便黏在檔尾', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
await run(['--estimate-days', '3'], stub);
|
||||
|
||||
const updated = patchOf(stub).body.body;
|
||||
const relation = updated.slice(updated.indexOf('## 關聯'));
|
||||
assert.match(relation, /需求議題:#1/);
|
||||
assert.match(relation, /估算人天:3/);
|
||||
});
|
||||
|
||||
test('重跑改估算時就地更新那一行,不會累積成兩行', async (t) => {
|
||||
const stub = await withStub(t, {}, {
|
||||
body: '## 關聯\n\n需求議題:#1\n估算人天:3\n',
|
||||
});
|
||||
|
||||
await run(['--estimate-days', '5'], stub);
|
||||
|
||||
const updated = patchOf(stub).body.body;
|
||||
assert.equal((updated.match(/估算人天:/g) ?? []).length, 1);
|
||||
assert.match(updated, /估算人天:5/);
|
||||
});
|
||||
|
||||
test('估算沒有變時不重寫 body', async (t) => {
|
||||
const stub = await withStub(t, {}, {
|
||||
body: '## 關聯\n\n需求議題:#1\n估算人天:3\n',
|
||||
});
|
||||
|
||||
await run(['--estimate-days', '3'], stub);
|
||||
|
||||
assert.equal('body' in patchOf(stub).body, false, '沒變就不該把 body 塞進 PATCH');
|
||||
});
|
||||
|
||||
test('人天必須是正數', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(['--estimate-days', '0'], stub);
|
||||
|
||||
assert.equal(json.error.code, 'BAD_ESTIMATE');
|
||||
assert.equal(stub.requests.length, 0);
|
||||
});
|
||||
|
||||
test('議題沒有關聯段落時,估算補在 body 結尾而不是靜靜消失', async (t) => {
|
||||
const stub = await withStub(t, {}, { body: '## 描述\n\n沒有關聯段落的議題。\n' });
|
||||
|
||||
await run(['--estimate-days', '2'], stub);
|
||||
|
||||
assert.match(patchOf(stub).body.body, /估算人天:2/);
|
||||
});
|
||||
|
||||
// ── 一起送 ─────────────────────────────────────────────────────────
|
||||
|
||||
test('三件事一次 PATCH 送出', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
await run(['--milestone', '第一階段', '--due-date', '2026-09-24', '--estimate-days', '2'], stub);
|
||||
|
||||
const patches = stub.requests.filter((r) => r.method === 'PATCH' && !r.path.endsWith('/0'));
|
||||
assert.equal(patches.length, 1);
|
||||
assert.equal(patches[0].body.milestone, 3);
|
||||
assert.equal(patches[0].body.due_date, '2026-09-24T00:00:00Z');
|
||||
assert.match(patches[0].body.body, /估算人天:2/);
|
||||
});
|
||||
|
||||
test('什麼都沒指定時中止,不發空的 PATCH', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run([], stub);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'NOTHING_TO_UPDATE');
|
||||
assert.equal(stub.requests.length, 0);
|
||||
});
|
||||
|
||||
// ── --dry-run ─────────────────────────────────────────────────────
|
||||
|
||||
test('--dry-run 列出將送出的 PATCH,且不寫入', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run(['--milestone', '第一階段', '--due-date', '2026-09-24', '--dry-run'], stub);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.dryRun, true);
|
||||
assert.equal(json.data.requests.length, 1);
|
||||
assert.equal(json.data.requests[0].body.milestone, 3, '預覽要看得出 Milestone 已解析成 id');
|
||||
assert.equal(stub.requests.some((r) => r.method !== 'GET'), false);
|
||||
});
|
||||
|
||||
// ── 估算那一行只能動目標段落(以下三條皆為實際重現過的污染情境)──────
|
||||
|
||||
test('圍欄裡的假標題不算段落,估算不會被塞進圍欄之後的位置', async (t) => {
|
||||
// 後面放一個真的段落,才分得出「當成段落處理」與「找不到段落」兩種結果
|
||||
const stub = await withStub(t, {}, {
|
||||
body: '## 描述\n\n說明\n\n```\n## 關聯\n```\n\n## 整體驗收\n\n- 一條\n',
|
||||
});
|
||||
|
||||
await run(['--estimate-days', '3'], stub);
|
||||
|
||||
const updated = patchOf(stub).body.body;
|
||||
assert.match(updated, /```\n## 關聯\n```/, '圍欄內容一字不動');
|
||||
assert.ok(
|
||||
updated.indexOf('估算人天:3') > updated.indexOf('## 整體驗收'),
|
||||
'沒有真正的關聯段落,估算該補在 body 結尾,而不是插在假標題那一段裡',
|
||||
);
|
||||
});
|
||||
|
||||
test('段落名稱是子字串時不誤認:## 關聯度說明 不是 ## 關聯', async (t) => {
|
||||
const stub = await withStub(t, {}, {
|
||||
body: '## 關聯度說明\n\n這不是關聯段落\n\n## 整體驗收\n\n- 一條\n',
|
||||
});
|
||||
|
||||
await run(['--estimate-days', '3'], stub);
|
||||
|
||||
const updated = patchOf(stub).body.body;
|
||||
assert.match(updated, /## 關聯度說明\n\n這不是關聯段落/, '別人的段落不得被動到');
|
||||
assert.ok(
|
||||
updated.indexOf('估算人天:3') > updated.indexOf('## 整體驗收'),
|
||||
'名稱只是相像不算命中,該落到 body 結尾',
|
||||
);
|
||||
});
|
||||
|
||||
test('別的段落剛好有同前綴的一行時,改的仍是關聯段落那一行', async (t) => {
|
||||
const stub = await withStub(t, {}, {
|
||||
body: '## 總覽\n\n估算人天:99\n\n## 關聯\n\n需求議題:#1\n',
|
||||
});
|
||||
|
||||
await run(['--estimate-days', '3'], stub);
|
||||
|
||||
const updated = patchOf(stub).body.body;
|
||||
assert.match(updated, /## 總覽\n\n估算人天:99/, '總覽那一行不得被改掉');
|
||||
const relation = updated.slice(updated.indexOf('## 關聯'));
|
||||
assert.match(relation, /估算人天:3/);
|
||||
});
|
||||
|
||||
test('估算插在段落內容結尾,不會掉到下一個段落裡', async (t) => {
|
||||
const stub = await withStub(t, {}, {
|
||||
body: '## 關聯\n\n需求議題:#1\n\n## 整體驗收\n\n- 一條\n',
|
||||
});
|
||||
|
||||
await run(['--estimate-days', '2'], stub);
|
||||
|
||||
const updated = patchOf(stub).body.body;
|
||||
assert.ok(
|
||||
updated.indexOf('估算人天:2') < updated.indexOf('## 整體驗收'),
|
||||
'估算要留在關聯段落內',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 把工作包放進 Gitea 專案看板。
|
||||
*
|
||||
* Gitea 1.27 沒有「列出專案」的 endpoint,所以專案 id 只能反查:掃最近的議題,
|
||||
* 從它們身上的 projects 欄位湊出 id→名稱對照。全空時就只能請使用者貼網址。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { runScript } from './helpers/run-script.js';
|
||||
import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
const INDEX = 12;
|
||||
|
||||
function routes(overrides = {}, { issueProjects = [], recent = null } = {}) {
|
||||
const recentIssues = recent ?? [
|
||||
{ number: 9, projects: [{ id: 7, title: '開發看板' }] },
|
||||
{ number: 8, projects: [{ id: 8, title: '維運看板' }] },
|
||||
{ number: 7, projects: [] },
|
||||
];
|
||||
return healthyRoutes(REPO, {
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
|
||||
status: 200,
|
||||
body: { number: INDEX, projects: issueProjects, html_url: 'https://example.com/12' },
|
||||
},
|
||||
[`GET /api/v1/repos/${REPO}/issues`]: { status: 200, body: recentIssues },
|
||||
[`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`]: (req) => ({
|
||||
status: 200,
|
||||
body: { number: INDEX, ...req.body },
|
||||
}),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options));
|
||||
|
||||
const run = (args, stub) =>
|
||||
runScript('project-add.js', ['--repo', REPO, '--index', String(INDEX), ...args], {
|
||||
env: envFor(stub),
|
||||
});
|
||||
|
||||
const patchOf = (stub) => stub.requests.find((r) => r.method === 'PATCH' && !r.path.endsWith('/0'));
|
||||
|
||||
// ── 以名稱指定 ─────────────────────────────────────────────────────
|
||||
|
||||
test('以名稱指定看板,靠掃議題反查出 id', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run(['--project', '開發看板'], stub);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
assert.deepEqual(patchOf(stub).body.projects, [7]);
|
||||
assert.equal(json.data.project.id, 7);
|
||||
assert.equal(json.data.project.title, '開發看板');
|
||||
});
|
||||
|
||||
test('反查只掃最近 50 筆議題', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
await run(['--project', '開發看板'], stub);
|
||||
|
||||
const scan = stub.requests.find((r) => r.method === 'GET' && r.path.endsWith('/issues'));
|
||||
assert.equal(scan.query.limit, '50');
|
||||
assert.equal(scan.query.state, 'all');
|
||||
});
|
||||
|
||||
test('反查不到名稱時,要求使用者貼專案網址', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run(['--project', '不存在的看板'], stub);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'PROJECT_NOT_FOUND');
|
||||
assert.match(json.error.message, /網址/);
|
||||
assert.match(json.error.message, /開發看板/, '要列出反查到的看板');
|
||||
});
|
||||
|
||||
test('最近的議題全都沒有看板時,說清楚為什麼查不到', async (t) => {
|
||||
const stub = await withStub(t, {}, { recent: [{ number: 9, projects: [] }] });
|
||||
|
||||
const { json } = await run(['--project', '開發看板'], stub);
|
||||
|
||||
assert.equal(json.error.code, 'PROJECT_NOT_FOUND');
|
||||
assert.match(json.error.message, /沒有任何一顆掛在看板上/);
|
||||
assert.match(json.error.message, /請直接貼專案網址/);
|
||||
});
|
||||
|
||||
// ── 以網址指定 ─────────────────────────────────────────────────────
|
||||
|
||||
test('直接貼專案網址時,取結尾的 id,不必反查', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code } = await run(['--project', 'https://gitea.jsc.idv.tw/plugins/-/projects/7'], stub);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.deepEqual(patchOf(stub).body.projects, [7]);
|
||||
assert.equal(
|
||||
stub.requests.some((r) => r.method === 'GET' && r.path.endsWith('/issues')),
|
||||
false,
|
||||
'網址已經帶了 id,不該再去掃議題',
|
||||
);
|
||||
});
|
||||
|
||||
test('網址結尾不是數字時中止', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(['--project', 'https://gitea.jsc.idv.tw/plugins/-/projects/abc'], stub);
|
||||
|
||||
assert.equal(json.error.code, 'PROJECT_NOT_FOUND');
|
||||
});
|
||||
|
||||
// ── 不覆蓋既有看板 ─────────────────────────────────────────────────
|
||||
|
||||
test('議題原本就在別的看板上時,保留它並補上新的', async (t) => {
|
||||
const stub = await withStub(t, {}, { issueProjects: [{ id: 8, title: '維運看板' }] });
|
||||
|
||||
await run(['--project', '開發看板'], stub);
|
||||
|
||||
assert.deepEqual(patchOf(stub).body.projects.sort(), [7, 8], 'projects 是整份取代,漏了就等於把人踢出原看板');
|
||||
});
|
||||
|
||||
test('已經在目標看板上時不重發 PATCH', async (t) => {
|
||||
const stub = await withStub(t, {}, { issueProjects: [{ id: 7, title: '開發看板' }] });
|
||||
|
||||
const { code, json } = await run(['--project', '開發看板'], stub);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(patchOf(stub), undefined);
|
||||
assert.equal(json.data.alreadyThere, true);
|
||||
});
|
||||
|
||||
// ── 不建立專案 ─────────────────────────────────────────────────────
|
||||
|
||||
test('不建立專案:唯一的寫入是議題本身的 PATCH,且只帶 projects', async (t) => {
|
||||
// 用「路徑含 project」當條件是空話——Gitea 的專案根本沒有 API 路徑
|
||||
const stub = await withStub(t);
|
||||
|
||||
await run(['--project', '開發看板'], stub);
|
||||
|
||||
// 前置檢查的寫入權探針也是 PATCH,但它打在不存在的 /issues/0 上,不算真正的寫入
|
||||
const writes = stub.requests.filter(
|
||||
(r) => r.method !== 'GET' && !r.path.endsWith('/issues/0'),
|
||||
);
|
||||
assert.equal(writes.length, 1);
|
||||
assert.equal(writes[0].method, 'PATCH');
|
||||
assert.equal(writes[0].path, `/api/v1/repos/${REPO}/issues/${INDEX}`);
|
||||
assert.deepEqual(Object.keys(writes[0].body), ['projects']);
|
||||
});
|
||||
|
||||
// ── --dry-run ─────────────────────────────────────────────────────
|
||||
|
||||
test('--dry-run 列出將送出的 PATCH,且不寫入', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run(['--project', '開發看板', '--dry-run'], stub);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.dryRun, true);
|
||||
assert.deepEqual(json.data.requests[0].body.projects, [7], '預覽要看得出解析出來的 id');
|
||||
assert.equal(stub.requests.some((r) => r.method !== 'GET'), false);
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* 截止日推算:依相依關係拓撲排序,任一工作包的截止日不得早於它的先決工作包。
|
||||
* 這支腳本不碰 Gitea,純算數字,所以測試只餵檔案、只看 stdout。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { runScript, tmpRoot } from './helpers/run-script.js';
|
||||
|
||||
/** 把一份排程計畫寫成暫存檔,回傳路徑 */
|
||||
function writePlan(plan) {
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
const dir = mkdtempSync(join(tmpRoot, 'plan-'));
|
||||
const path = join(dir, 'plan.json');
|
||||
writeFileSync(path, JSON.stringify(plan));
|
||||
return path;
|
||||
}
|
||||
|
||||
const run = (plan, args = []) =>
|
||||
runScript('schedule.js', ['--plan-file', writePlan(plan), ...args]);
|
||||
|
||||
/** 依 index 取出算出來的截止日 */
|
||||
const dueOf = (json) =>
|
||||
Object.fromEntries(json.data.schedule.map((wp) => [wp.index, wp.dueDate]));
|
||||
|
||||
test('沒有相依時,每顆各自從起始日加上自己的人天', async () => {
|
||||
const { code, json } = await run({
|
||||
startDate: '2026-09-21',
|
||||
workPackages: [
|
||||
{ index: 1, title: '甲', days: 2 },
|
||||
{ index: 2, title: '乙', days: 5 },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.deepEqual(dueOf(json), { 1: '2026-09-23', 2: '2026-09-26' });
|
||||
});
|
||||
|
||||
test('有先決時,截止日從先決的截止日往後算', async () => {
|
||||
const { json } = await run({
|
||||
startDate: '2026-09-21',
|
||||
workPackages: [
|
||||
{ index: 1, title: '甲', days: 2 },
|
||||
{ index: 2, title: '乙', days: 3, depends: [1] },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(dueOf(json), { 1: '2026-09-23', 2: '2026-09-26' });
|
||||
});
|
||||
|
||||
test('多個先決時取最晚的那一個當起點', async () => {
|
||||
const { json } = await run({
|
||||
startDate: '2026-09-21',
|
||||
workPackages: [
|
||||
{ index: 1, title: '甲', days: 2 },
|
||||
{ index: 2, title: '乙', days: 9 },
|
||||
{ index: 3, title: '丙', days: 1, depends: [1, 2] },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(dueOf(json)[3], '2026-10-01', '要等最晚的乙做完才開始');
|
||||
});
|
||||
|
||||
test('任一工作包的截止日都不早於它的先決', async () => {
|
||||
const { json } = await run({
|
||||
startDate: '2026-09-21',
|
||||
workPackages: [
|
||||
{ index: 1, title: '甲', days: 3 },
|
||||
{ index: 2, title: '乙', days: 1, depends: [1] },
|
||||
{ index: 3, title: '丙', days: 1, depends: [2] },
|
||||
{ index: 4, title: '丁', days: 4, depends: [1] },
|
||||
{ index: 5, title: '戊', days: 2, depends: [3, 4] },
|
||||
],
|
||||
});
|
||||
|
||||
const due = dueOf(json);
|
||||
const depends = { 2: [1], 3: [2], 4: [1], 5: [3, 4] };
|
||||
for (const [index, prerequisites] of Object.entries(depends)) {
|
||||
for (const p of prerequisites) {
|
||||
assert.ok(due[index] > due[p], `#${index} 的截止日不該早於先決 #${p}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('輸出的順序即拓撲順序,先決一定排在後續之前', async () => {
|
||||
const { json } = await run({
|
||||
startDate: '2026-09-21',
|
||||
workPackages: [
|
||||
{ index: 3, title: '丙', days: 1, depends: [2] },
|
||||
{ index: 1, title: '甲', days: 1 },
|
||||
{ index: 2, title: '乙', days: 1, depends: [1] },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(json.data.order, [1, 2, 3]);
|
||||
});
|
||||
|
||||
test('相依成環時中止,並指出環上的成員', async () => {
|
||||
const { code, json } = await run({
|
||||
startDate: '2026-09-21',
|
||||
workPackages: [
|
||||
{ index: 1, title: '甲', days: 1, depends: [2] },
|
||||
{ index: 2, title: '乙', days: 1, depends: [1] },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'CYCLE_DETECTED');
|
||||
assert.match(json.error.message, /1/);
|
||||
assert.match(json.error.message, /2/);
|
||||
});
|
||||
|
||||
test('先決指向不存在的工作包時中止', async () => {
|
||||
const { code, json } = await run({
|
||||
startDate: '2026-09-21',
|
||||
workPackages: [{ index: 1, title: '甲', days: 1, depends: [99] }],
|
||||
});
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'UNKNOWN_DEPENDENCY');
|
||||
assert.match(json.error.message, /99/);
|
||||
});
|
||||
|
||||
test('人天必須是正數', async () => {
|
||||
const { json } = await run({
|
||||
startDate: '2026-09-21',
|
||||
workPackages: [{ index: 1, title: '甲', days: 0 }],
|
||||
});
|
||||
|
||||
assert.equal(json.error.code, 'BAD_PLAN');
|
||||
assert.match(json.error.message, /days/);
|
||||
});
|
||||
|
||||
test('起始日格式不對時中止', async () => {
|
||||
const { json } = await run({
|
||||
startDate: '2026/09/21',
|
||||
workPackages: [{ index: 1, title: '甲', days: 1 }],
|
||||
});
|
||||
|
||||
assert.equal(json.error.code, 'BAD_PLAN');
|
||||
assert.match(json.error.message, /startDate/);
|
||||
});
|
||||
|
||||
test('計畫檔不存在時帶著路徑失敗', async () => {
|
||||
const { code, json } = await runScript('schedule.js', ['--plan-file', '/nonexistent/plan.json']);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'PLAN_FILE_MISSING');
|
||||
assert.match(json.error.message, /\/nonexistent\/plan\.json/);
|
||||
});
|
||||
|
||||
test('計畫檔不是合法 JSON 時中止', async () => {
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
const dir = mkdtempSync(join(tmpRoot, 'plan-'));
|
||||
const path = join(dir, 'plan.json');
|
||||
writeFileSync(path, '{ 這不是 JSON');
|
||||
|
||||
const { json } = await runScript('schedule.js', ['--plan-file', path]);
|
||||
|
||||
assert.equal(json.error.code, 'BAD_PLAN');
|
||||
});
|
||||
|
||||
test('輸出保留標題與人天,呼叫端不必回頭對照計畫檔', async () => {
|
||||
const { json } = await run({
|
||||
startDate: '2026-09-21',
|
||||
workPackages: [{ index: 7, title: '建立抽取契約', days: 3 }],
|
||||
});
|
||||
|
||||
assert.deepEqual(json.data.schedule[0], {
|
||||
index: 7,
|
||||
title: '建立抽取契約',
|
||||
days: 3,
|
||||
dueDate: '2026-09-24',
|
||||
});
|
||||
});
|
||||
|
||||
test('這支腳本完全不碰 Gitea:沒有登入資訊也能算', async () => {
|
||||
const { code } = await runScript(
|
||||
'schedule.js',
|
||||
['--plan-file', writePlan({ startDate: '2026-09-21', workPackages: [{ index: 1, title: '甲', days: 1 }] })],
|
||||
{ env: { TEA_SDLC_CONFIG: '/nonexistent/tea.yml' } },
|
||||
);
|
||||
|
||||
assert.equal(code, 0);
|
||||
});
|
||||
|
||||
test('同一個 index 出現兩次時中止,不把兩份定義混在一起算', async () => {
|
||||
// 混出來的結果會是:相依看後者、標題與人天取前者,而且完全不報錯
|
||||
const { code, json } = await run({
|
||||
startDate: '2026-09-21',
|
||||
workPackages: [
|
||||
{ index: 1, title: '第一份定義', days: 1 },
|
||||
{ index: 1, title: '第二份定義', days: 100 },
|
||||
{ index: 2, title: '乙', days: 1, depends: [1] },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'BAD_PLAN');
|
||||
assert.match(json.error.message, /不只一次/);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 正本第三段「排上時程與看板」的規則。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { assertNeutralPrompt, readPrompt } from './helpers/prompt-doc.js';
|
||||
|
||||
const prompt = readPrompt('sdlc-analyze');
|
||||
const phase3 = prompt.slice(prompt.indexOf('## 第三段'), prompt.indexOf('## 架構圖的限制'));
|
||||
|
||||
test('正本仍然平台中立,description 前綴正確', () => {
|
||||
assertNeutralPrompt(prompt, 'sdlc-analyze');
|
||||
});
|
||||
|
||||
test('第三段指名四支腳本,順序為先算再寫', () => {
|
||||
const order = ['schedule.js', 'issue-link.js', 'issue-update.js', 'project-add.js'];
|
||||
const positions = order.map((name) => phase3.indexOf(name));
|
||||
assert.equal(positions.every((p) => p >= 0), true, '四支腳本都要被指名');
|
||||
assert.deepEqual([...positions].sort((a, b) => a - b), positions, '要先算出截止日才寫得下去');
|
||||
});
|
||||
|
||||
test('計畫檔的格式有可照抄的範例', () => {
|
||||
assert.match(phase3, /"startDate"/);
|
||||
assert.match(phase3, /"workPackages"/);
|
||||
assert.match(phase3, /"depends"/);
|
||||
});
|
||||
|
||||
test('交代了拓撲排序保證什麼,以及成環時怎麼辦', () => {
|
||||
assert.match(phase3, /截止日都不早於它的先決/);
|
||||
assert.match(phase3, /成環/);
|
||||
assert.match(phase3, /回頭改拆法/);
|
||||
});
|
||||
|
||||
test('說明日期只算日曆日,不替使用者決定跳哪些日子', () => {
|
||||
assert.match(phase3, /日曆日/);
|
||||
assert.match(phase3, /不跳週末/);
|
||||
});
|
||||
|
||||
test('三支寫入腳本都要求先試跑,並點出它們是冪等的', () => {
|
||||
assert.match(phase3, /--dry-run/);
|
||||
assert.match(phase3, /冪等/);
|
||||
});
|
||||
|
||||
test('Milestone 與看板都只掛既有的,且交代反查不到時怎麼辦', () => {
|
||||
assert.match(phase3, /只掛既有的/);
|
||||
assert.match(phase3, /不建立 Milestone/);
|
||||
assert.match(phase3, /不建立專案/);
|
||||
assert.match(phase3, /貼專案網址/);
|
||||
});
|
||||
|
||||
test('回報時要指出相依鏈最長路徑', () => {
|
||||
assert.match(phase3, /相依鏈最長路徑/);
|
||||
});
|
||||
|
||||
test('人天估算的 API 限制寫成獨立一節,不是藏在行文裡', () => {
|
||||
const limit = prompt.slice(prompt.indexOf('## 已知限制'), prompt.indexOf('## 架構圖的限制'));
|
||||
assert.match(limit, /time_estimate/);
|
||||
assert.match(limit, /無法由 API 寫入/);
|
||||
assert.match(limit, /估算人天:/, '要說清楚改寫到哪裡去');
|
||||
assert.match(limit, /sdlc-report/, '要說清楚誰會讀這一行');
|
||||
});
|
||||
|
||||
test('邊界把三段各自不做的事分開列', () => {
|
||||
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
|
||||
assert.match(boundary, /共識摘要之前不對 Gitea 產生任何寫入/);
|
||||
assert.match(boundary, /第二段只建立工作包議題/);
|
||||
assert.match(boundary, /第三段只掛既有的 Milestone 與看板/);
|
||||
});
|
||||
Reference in New Issue
Block a user