From 0f53567737d524ea294ffa17f59d8774f24dcce1 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 06:06:03 +0000 Subject: [PATCH 01/17] =?UTF-8?q?feat(=E6=8E=92=E7=A8=8B):=20=E4=BE=9D?= =?UTF-8?q?=E7=9B=B8=E4=BE=9D=E9=97=9C=E4=BF=82=E6=8B=93=E6=92=B2=E6=8E=A8?= =?UTF-8?q?=E7=AE=97=E6=88=AA=E6=AD=A2=E6=97=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保證一件事:任一工作包的截止日都不早於它的先決。人工排時程最常出現的矛盾就是 前置工作比後續還晚到期,看板上看起來合理、實際上做不到。 以 Kahn 演算法排序,排不完就代表有環,而環上的成員正是排不進去的那些,直接把 它們列出來——相依成環是拆法有問題,硬排沒有意義。 日期以日曆日累加,不跳週末也不扣假日:跳過哪些日子是團隊政策,這裡不替使用者 決定。這支腳本不碰 Gitea 也不碰 git,純算數字,因此沒有前置檢查也不需要登入。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/schedule.js | 130 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 scripts/schedule.js diff --git a/scripts/schedule.js b/scripts/schedule.js new file mode 100644 index 0000000..aa04c86 --- /dev/null +++ b/scripts/schedule.js @@ -0,0 +1,130 @@ +#!/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); +} From be477a4391ee3f4ce81b9e9b80d854dcf0221b7a Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 06:06:04 +0000 Subject: [PATCH 02/17] =?UTF-8?q?test(=E6=8E=92=E7=A8=8B):=20=E8=A6=86?= =?UTF-8?q?=E8=93=8B=E6=8B=93=E6=92=B2=E9=A0=86=E5=BA=8F=E3=80=81=E5=A4=9A?= =?UTF-8?q?=E9=87=8D=E5=85=88=E6=B1=BA=E8=88=87=E6=88=90=E7=92=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 除了逐例比對日期,另有一條測試直接驗「所有先決關係都滿足」這個不變式, 而不是只比對硬編的日期字串——不變式壞掉時它會指出是哪一對。 Co-Authored-By: Claude Opus 5 (1M context) --- test/schedule.test.js | 186 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 test/schedule.test.js diff --git a/test/schedule.test.js b/test/schedule.test.js new file mode 100644 index 0000000..6277f01 --- /dev/null +++ b/test/schedule.test.js @@ -0,0 +1,186 @@ +/** + * 截止日推算:依相依關係拓撲排序,任一工作包的截止日不得早於它的先決工作包。 + * 這支腳本不碰 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); +}); From b1025168c0ba27637ab8bbef1e8123b87d7ea9f2 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 06:06:04 +0000 Subject: [PATCH 03/17] =?UTF-8?q?feat(issue-link):=20=E5=BB=BA=E7=AB=8B?= =?UTF-8?q?=E8=AD=B0=E9=A1=8C=E4=B9=8B=E9=96=93=E7=9A=84=E9=98=BB=E6=93=8B?= =?UTF-8?q?=E8=88=87=E5=85=88=E6=B1=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --depends 是「這顆被誰擋住」,--blocks 是「這顆擋住誰」。先讀現況只補缺的那幾條, 重跑不會在 Gitea 上堆出重複的相依。 請求必須帶 owner 與 repo:Gitea 的相依端點少了它們會回 404,而且訊息是「repository does not exist」,不看文件會以為是路徑寫錯。 自己依賴自己擋在發出請求之前——Gitea 會接受,但那會讓後續的拓撲排序永遠排不完。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/issue-link.js | 110 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 scripts/issue-link.js diff --git a/scripts/issue-link.js b/scripts/issue-link.js new file mode 100644 index 0000000..722ec28 --- /dev/null +++ b/scripts/issue-link.js @@ -0,0 +1,110 @@ +#!/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, + 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 }; +}); + +function parseIndex(value, what) { + if (!/^[1-9]\d*$/.test(String(value))) { + throw new ScriptError('BAD_INDEX', `${what} 需為正整數,收到的是 ${value}`); + } + return Number(value); +} + +/** + * 解析逗號分隔的議題編號。 + * 自己依賴自己在 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; +} From b45437e5275e01a0170078e1feca8fcd975de44a Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 06:06:05 +0000 Subject: [PATCH 04/17] =?UTF-8?q?test(issue-link):=20=E8=A6=86=E8=93=8B?= =?UTF-8?q?=E5=85=A9=E7=A8=AE=E7=9B=B8=E4=BE=9D=E3=80=81=E5=86=AA=E7=AD=89?= =?UTF-8?q?=E8=88=87=E8=87=AA=E6=88=91=E7=9B=B8=E4=BE=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- test/issue-link.test.js | 165 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 test/issue-link.test.js diff --git a/test/issue-link.test.js b/test/issue-link.test.js new file mode 100644 index 0000000..adf4a7f --- /dev/null +++ b/test/issue-link.test.js @@ -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); +}); From 45bfd99d803ec4314258018e38ccc21b1f4ce3b9 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 06:06:05 +0000 Subject: [PATCH 05/17] =?UTF-8?q?feat(issue-update):=20=E6=8E=9B=20Milesto?= =?UTF-8?q?ne=E3=80=81=E5=AF=AB=E6=88=AA=E6=AD=A2=E6=97=A5=E3=80=81?= =?UTF-8?q?=E8=A8=98=E4=BA=BA=E5=A4=A9=E4=BC=B0=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三件事經同一個 PATCH 送出,因為排時程時它們幾乎總是一起改,分開發等於多兩次往返。 Milestone 只認既有的:指到不存在的就中止並列出可選項目,本工具不建立 Milestone。 人天估算只寫進 body 的人類可讀一行。Gitea 1.27 的 API 沒有任何請求定義接受 time_estimate——它只出現在 Issue 的回應裡——所以議題的估算欄位無法由 API 寫入。 新增的 upsertLineInSection 負責就地更新那一行:重跑改估算不會累積成兩行,值沒變 就不把 body 塞進 PATCH,免得在議題上留下一筆沒有內容的編輯紀錄。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/issue-body.js | 37 +++++++++++++ scripts/issue-update.js | 119 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 scripts/issue-update.js diff --git a/scripts/issue-body.js b/scripts/issue-body.js index f1cc834..972a282 100644 --- a/scripts/issue-body.js +++ b/scripts/issue-body.js @@ -141,3 +141,40 @@ 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 existing = new RegExp(`^${escapeRegExp(prefix)}.*$`, 'm'); + + if (existing.test(body)) { + const updated = body.replace(existing, line); + return updated; + } + + const heading = `## ${section}`; + const start = body.indexOf(heading); + if (start === -1) { + return `${body.replace(/\n*$/, '')}\n\n${line}\n`; + } + + // 段落的結尾是下一個標題之前,或整份 body 的結尾 + const after = body.indexOf('\n## ', start + heading.length); + const end = after === -1 ? body.length : after; + const head = body.slice(0, end).replace(/\n*$/, ''); + return `${head}\n${line}\n${body.slice(end).replace(/^\n+/, '\n')}`; +} + +function escapeRegExp(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/scripts/issue-update.js b/scripts/issue-update.js new file mode 100644 index 0000000..8dd0eda --- /dev/null +++ b/scripts/issue-update.js @@ -0,0 +1,119 @@ +#!/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, + 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 parseIndex(value) { + if (!/^[1-9]\d*$/.test(value)) { + throw new ScriptError('BAD_INDEX', `--index 需為正整數,收到的是 ${value}`); + } + return Number(value); +} + +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; +} From 3f97fe09013390009c5e67b7bc1b22de36009464 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 06:06:06 +0000 Subject: [PATCH 06/17] =?UTF-8?q?test(issue-update):=20=E8=A6=86=E8=93=8B?= =?UTF-8?q?=20Milestone=20=E8=A7=A3=E6=9E=90=E3=80=81=E6=97=A5=E6=9C=9F?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E8=88=87=E4=BC=B0=E7=AE=97=E7=9A=84=E5=B0=B1?= =?UTF-8?q?=E5=9C=B0=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- test/issue-update.test.js | 191 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 test/issue-update.test.js diff --git a/test/issue-update.test.js b/test/issue-update.test.js new file mode 100644 index 0000000..ddc6021 --- /dev/null +++ b/test/issue-update.test.js @@ -0,0 +1,191 @@ +/** + * 掛 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); +}); From 770bf686cef36487384c43c587c29c7a08b3136e Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 06:06:06 +0000 Subject: [PATCH 07/17] =?UTF-8?q?feat(project-add):=20=E6=8A=8A=E8=AD=B0?= =?UTF-8?q?=E9=A1=8C=E6=94=BE=E9=80=B2=E7=9C=8B=E6=9D=BF=EF=BC=8C=E7=9C=8B?= =?UTF-8?q?=E6=9D=BF=20id=20=E9=9D=A0=E6=8E=83=E8=AD=B0=E9=A1=8C=E5=8F=8D?= =?UTF-8?q?=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gitea 1.27 沒有「列出專案」的 endpoint,名稱只能反查:掃最近 50 筆議題,從它們 身上的 projects 欄位湊出 id→名稱對照。全都沒掛看板時湊不出來,這時請使用者直接 貼專案網址,結尾即 id。 projects 欄位是整份取代不是附加,所以要先讀出議題既有的看板再聯集——漏掉就等於 把這顆議題踢出原本的看板。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/project-add.js | 115 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 scripts/project-add.js diff --git a/scripts/project-add.js b/scripts/project-add.js new file mode 100644 index 0000000..18461af --- /dev/null +++ b/scripts/project-add.js @@ -0,0 +1,115 @@ +#!/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, + 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 }; +}); + +function parseIndex(value) { + if (!/^[1-9]\d*$/.test(value)) { + throw new ScriptError('BAD_INDEX', `--index 需為正整數,收到的是 ${value}`); + } + return Number(value); +} + +/** + * 把 `--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()]; +} From 5327f5882e5c8d37f7c1afbfc11b84d83ba01276 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 06:06:07 +0000 Subject: [PATCH 08/17] =?UTF-8?q?test(project-add):=20=E8=A6=86=E8=93=8B?= =?UTF-8?q?=E5=8F=8D=E6=9F=A5=E3=80=81=E8=B2=BC=E7=B6=B2=E5=9D=80=E3=80=81?= =?UTF-8?q?=E6=97=A2=E6=9C=89=E7=9C=8B=E6=9D=BF=E4=B8=8D=E8=A2=AB=E8=A6=86?= =?UTF-8?q?=E8=93=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- test/project-add.test.js | 156 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 test/project-add.test.js diff --git a/test/project-add.test.js b/test/project-add.test.js new file mode 100644 index 0000000..ff3ec3c --- /dev/null +++ b/test/project-add.test.js @@ -0,0 +1,156 @@ +/** + * 把工作包放進 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('不建立專案:沒有任何非 GET 請求打到專案相關端點', async (t) => { + const stub = await withStub(t); + + await run(['--project', '開發看板'], stub); + + const creations = stub.requests.filter( + (r) => r.method !== 'GET' && /project/i.test(r.path), + ); + assert.deepEqual(creations, []); +}); + +// ── --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); +}); From c6354657519a00432150e6787249ab2c91056832 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 06:06:08 +0000 Subject: [PATCH 09/17] =?UTF-8?q?feat(sdlc-analyze):=20=E5=8A=A0=E5=85=A5?= =?UTF-8?q?=E7=AC=AC=E4=B8=89=E6=AE=B5=EF=BC=8C=E6=8A=8A=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=85=E6=8E=92=E4=B8=8A=E6=99=82=E7=A8=8B=E8=88=87=E7=9C=8B?= =?UTF-8?q?=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 先算後寫:schedule 算出截止日,再由 issue-link、issue-update、project-add 逐顆 補上相依、時程與看板。三支都先試跑再實跑,三支都是冪等的。 另立一節寫明人天估算的 API 限制,而不是把它藏在行文裡——estimate 只寫得進 body, sdlc-report 之後讀的也是那一行,這件事踩到才知道就太晚了。 邊界改成三段各自列:哪一段不做什麼講清楚,兩顆工作包才不會互相踩。 Closes #8 Co-Authored-By: Claude Opus 5 (1M context) --- prompts/sdlc-analyze.md | 73 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 6 deletions(-) diff --git a/prompts/sdlc-analyze.md b/prompts/sdlc-analyze.md index 5bbbf12..6b75a8f 100644 --- a/prompts/sdlc-analyze.md +++ b/prompts/sdlc-analyze.md @@ -1,12 +1,13 @@ name: sdlc-analyze -description: 僅由 /sdlc-analyze 指令叫用。對一顆需求議題執行可行性檢查,逐題問到共識後產生工作包議題。 +description: 僅由 /sdlc-analyze 指令叫用。對一顆需求議題執行可行性檢查,逐題問到共識後產生工作包議題並排上時程。 # sdlc-analyze 對一顆需求議題執行可行性檢查,把疑點一題一題問到雙方有共識,再把共識變成一批工作包議題。 -分成兩段:**可行性分析**到共識摘要為止,完全不寫入 Gitea;使用者看過摘要點頭之後, -才進入**產生工作包**,那一段才會建立議題。 +分成三段:**可行性分析**到共識摘要為止,完全不寫入 Gitea;使用者看過摘要點頭之後, +才進入**產生工作包**建立議題;最後**排上時程與看板**,把相依、截止日、Milestone、 +看板與人天估算補上。 這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。 @@ -128,8 +129,68 @@ 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 --index <編號> --depends <先決編號清單> +node scripts/issue-update.js --repo --index <編號> \ + --milestone "<既有 Milestone 名稱>" --due-date --estimate-days <人天> +node scripts/project-add.js --repo --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` 要比對估算與實際工時時, +讀的也是這一行。 + +## 架構圖的限制 依工作包的性質選圖: - **`sequenceDiagram`** — 重點在「誰呼叫誰、順序為何」時用。 @@ -146,8 +207,8 @@ no-op」。確認無誤後拿掉該旗標再跑一次。 ## 邊界 - **共識摘要之前不對 Gitea 產生任何寫入**:不建議題、不改描述、不貼標籤、不留留言。 -- 第二段只建立工作包議題。不建相依、不掛 Milestone、不加看板、不寫人天估算——那是後續流程的事。 -- 不自行建立標籤、Milestone 或專案看板。 +- 第二段只建立工作包議題。不建相依、不掛 Milestone、不加看板、不寫人天估算——那是第三段的事。 +- 第三段只掛既有的 Milestone 與看板。不自行建立標籤、Milestone 或專案看板。 - 不修改使用者的專案檔案。查證既有功能時只讀不寫。 - 不替使用者決定他沒回答的事。問不到答案就進「仍然未決的事」。 - 不關閉或刪除任何既有議題。 From 257642cb1c5b6b98624dc44ce2ee970eb4034efe Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 06:06:08 +0000 Subject: [PATCH 10/17] =?UTF-8?q?test(sdlc-analyze):=20=E9=87=98=E4=BD=8F?= =?UTF-8?q?=E7=AC=AC=E4=B8=89=E6=AE=B5=E7=9A=84=E8=85=B3=E6=9C=AC=E9=A0=86?= =?UTF-8?q?=E5=BA=8F=E8=88=87=E5=B7=B2=E7=9F=A5=E9=99=90=E5=88=B6=E9=82=A3?= =?UTF-8?q?=E4=B8=80=E7=AF=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- test/wp-schedule-assets.test.js | 68 +++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 test/wp-schedule-assets.test.js diff --git a/test/wp-schedule-assets.test.js b/test/wp-schedule-assets.test.js new file mode 100644 index 0000000..607e447 --- /dev/null +++ b/test/wp-schedule-assets.test.js @@ -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 與看板/); +}); From e494bd534e43921fa9d188dbd2c22e909ec2e5df Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 06:06:09 +0000 Subject: [PATCH 11/17] =?UTF-8?q?fix(=E8=AD=B0=E9=A1=8C=E8=A7=A3=E6=9E=90)?= =?UTF-8?q?:=20upsertLineInSection=20=E9=99=90=E5=AE=9A=E5=9C=A8=E7=9B=AE?= =?UTF-8?q?=E6=A8=99=E6=AE=B5=E8=90=BD=E5=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三個實際重現過的污染情境: - 圍欄裡的 `## 關聯` 被當成真標題,估算插進圍欄後面。本檔的共同前提是「圍欄裡的 東西不是內容」,這支卻自己用 indexOf 找標題,繞過了那個判斷。 - `## 關聯度說明` 被 indexOf 當成 `## 關聯` 命中,改到別人的段落。 - 「這一行是否已存在」用整份 body 比對,於是別的段落剛好有 `估算人天:` 時被改掉, 真正的關聯段落反而一直拿不到值。 改成沿用同檔的 eachLine 走行、標題要完全相同、既有那一行只在段落範圍內找。 弄錯的代價是靜靜改壞別人的內容,所以三道判斷都收緊。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/issue-body.js | 52 ++++++++++++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/scripts/issue-body.js b/scripts/issue-body.js index 972a282..bbff973 100644 --- a/scripts/issue-body.js +++ b/scripts/issue-body.js @@ -146,6 +146,12 @@ function isSeparator(cells) { * 在指定段落裡就地更新(或補上)一行「前綴+值」。 * * 用於人天估算這種「議題上只該有一行、重跑要覆蓋而不是累積」的欄位。 + * + * 三件事都限定在目標段落之內,因為弄錯的代價是靜靜改壞別人的內容: + * - 標題要完全相同才算數,`## 關聯度說明` 不是 `## 關聯`。 + * - 圍欄裡的假標題不算標題,沿用本檔共同的圍欄判斷。 + * - 找既有那一行只在段落範圍內找,別的段落剛好有同前綴時不會被改掉。 + * * 段落不存在時補在 body 結尾——寧可放錯位置,也不要讓值靜靜消失。 * * @param {string} body 議題 body @@ -155,26 +161,42 @@ function isSeparator(cells) { */ export function upsertLineInSection(body, section, line) { const prefix = line.slice(0, line.indexOf(':') + 1); - const existing = new RegExp(`^${escapeRegExp(prefix)}.*$`, 'm'); + const rows = [...eachLine(body)]; + const { start, end } = sectionBounds(rows, section); - if (existing.test(body)) { - const updated = body.replace(existing, line); - return updated; - } - - const heading = `## ${section}`; - const start = body.indexOf(heading); if (start === -1) { return `${body.replace(/\n*$/, '')}\n\n${line}\n`; } - // 段落的結尾是下一個標題之前,或整份 body 的結尾 - const after = body.indexOf('\n## ', start + heading.length); - const end = after === -1 ? body.length : after; - const head = body.slice(0, end).replace(/\n*$/, ''); - return `${head}\n${line}\n${body.slice(end).replace(/^\n+/, '\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'); } -function escapeRegExp(text) { - return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +/** 找出段落的起訖行號;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 }; } From e5b247e4cfb47b7e3baec4e756c7890b32d8a31e Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 06:06:09 +0000 Subject: [PATCH 12/17] =?UTF-8?q?fix(=E6=8E=92=E7=A8=8B):=20=E6=8B=92?= =?UTF-8?q?=E7=B5=95=E9=87=8D=E8=A4=87=E7=9A=84=E5=B7=A5=E4=BD=9C=E5=8C=85?= =?UTF-8?q?=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同一個 index 出現兩次時,相依看的是後者、標題與人天卻取到前者,算出來的時程是 兩份定義混出來的東西,而且回傳 ok:true 完全不報錯。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/schedule.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/schedule.js b/scripts/schedule.js index aa04c86..5a0f4ba 100644 --- a/scripts/schedule.js +++ b/scripts/schedule.js @@ -66,7 +66,15 @@ function readPlan(planFile) { 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}`); } From be6ceddde02842d97d301f4da2dba2949567a229 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 06:06:10 +0000 Subject: [PATCH 13/17] =?UTF-8?q?refactor(=E5=8F=83=E6=95=B8):=20parseInde?= =?UTF-8?q?x=20=E6=8A=BD=E5=88=B0=20lib=EF=BC=8C=E5=9B=9B=E6=94=AF?= =?UTF-8?q?=E8=85=B3=E6=9C=AC=E5=85=B1=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同一段 /^[1-9]\d*$/ 與 BAD_INDEX 已經抄了四份。lib 本來就放著同性質的 parseRepo, 這一支該待在它旁邊。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/issue-extract.js | 7 +------ scripts/issue-link.js | 7 +------ scripts/issue-update.js | 7 +------ scripts/lib.js | 13 +++++++++++++ scripts/project-add.js | 7 +------ 5 files changed, 17 insertions(+), 24 deletions(-) diff --git a/scripts/issue-extract.js b/scripts/issue-extract.js index d9a5423..6665c94 100644 --- a/scripts/issue-extract.js +++ b/scripts/issue-extract.js @@ -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); diff --git a/scripts/issue-link.js b/scripts/issue-link.js index 722ec28..d9f25c6 100644 --- a/scripts/issue-link.js +++ b/scripts/issue-link.js @@ -19,6 +19,7 @@ import { giteaRequest, main, parseFlags, + parseIndex, parseRepo, preflight, resolveLogin, @@ -82,12 +83,6 @@ main(async () => { return { repo, index, added, skipped }; }); -function parseIndex(value, what) { - if (!/^[1-9]\d*$/.test(String(value))) { - throw new ScriptError('BAD_INDEX', `${what} 需為正整數,收到的是 ${value}`); - } - return Number(value); -} /** * 解析逗號分隔的議題編號。 diff --git a/scripts/issue-update.js b/scripts/issue-update.js index 8dd0eda..17ac0e0 100644 --- a/scripts/issue-update.js +++ b/scripts/issue-update.js @@ -20,6 +20,7 @@ import { giteaRequest, main, parseFlags, + parseIndex, parseRepo, preflight, resolveLogin, @@ -76,12 +77,6 @@ main(async () => { }; }); -function parseIndex(value) { - if (!/^[1-9]\d*$/.test(value)) { - throw new ScriptError('BAD_INDEX', `--index 需為正整數,收到的是 ${value}`); - } - return Number(value); -} function parseDueDate(value) { if (value === undefined) return null; diff --git a/scripts/lib.js b/scripts/lib.js index 5b43d13..24b8d5c 100644 --- a/scripts/lib.js +++ b/scripts/lib.js @@ -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 diff --git a/scripts/project-add.js b/scripts/project-add.js index 18461af..85d8319 100644 --- a/scripts/project-add.js +++ b/scripts/project-add.js @@ -18,6 +18,7 @@ import { giteaRequest, main, parseFlags, + parseIndex, parseRepo, preflight, resolveLogin, @@ -61,12 +62,6 @@ main(async () => { return { repo, index, project, alreadyThere: false }; }); -function parseIndex(value) { - if (!/^[1-9]\d*$/.test(value)) { - throw new ScriptError('BAD_INDEX', `--index 需為正整數,收到的是 ${value}`); - } - return Number(value); -} /** * 把 `--project` 解析成 {id, title}。 From 7a6cf948f51b4b85a74f60e6e8db74a32511fc0f Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 06:06:10 +0000 Subject: [PATCH 14/17] =?UTF-8?q?fix(=E6=B5=81=E7=A8=8B=E6=AD=A3=E6=9C=AC)?= =?UTF-8?q?:=20=E8=A3=9C=E5=9B=9E=E6=A8=99=E9=A1=8C=E5=BE=8C=E8=A2=AB?= =?UTF-8?q?=E5=90=83=E6=8E=89=E7=9A=84=E7=A9=BA=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- prompts/sdlc-analyze.md | 1 + 1 file changed, 1 insertion(+) diff --git a/prompts/sdlc-analyze.md b/prompts/sdlc-analyze.md index 6b75a8f..d31b86b 100644 --- a/prompts/sdlc-analyze.md +++ b/prompts/sdlc-analyze.md @@ -191,6 +191,7 @@ Gitea 1.27 的 API 沒有任何請求定義接受 `time_estimate`,該欄位只 讀的也是這一行。 ## 架構圖的限制 + 依工作包的性質選圖: - **`sequenceDiagram`** — 重點在「誰呼叫誰、順序為何」時用。 From b09746a3098388643f5ba17fd1c0579f5d9415a0 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 06:06:11 +0000 Subject: [PATCH 15/17] =?UTF-8?q?test(=E6=8E=92=E7=A8=8B):=20=E8=A6=86?= =?UTF-8?q?=E8=93=8B=E9=87=8D=E8=A4=87=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- test/schedule.test.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/schedule.test.js b/test/schedule.test.js index 6277f01..fbbec8d 100644 --- a/test/schedule.test.js +++ b/test/schedule.test.js @@ -184,3 +184,19 @@ test('這支腳本完全不碰 Gitea:沒有登入資訊也能算', async () => 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, /不只一次/); +}); From 15da38a4601c61fc2e0412a2a81e7d62a1616853 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 06:06:11 +0000 Subject: [PATCH 16/17] =?UTF-8?q?test(issue-update):=20=E5=B0=81=E4=BD=8F?= =?UTF-8?q?=E4=BC=B0=E7=AE=97=E9=82=A3=E4=B8=80=E8=A1=8C=E7=9A=84=E4=B8=89?= =?UTF-8?q?=E7=A8=AE=E6=B1=A1=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三條測試都先確認過會在舊版實作上失敗。第一版寫出來時有兩條其實是陪跑的—— 情境裡沒有後續段落,正確與錯誤的結果剛好都落在 body 結尾,分不出來。加上一個 真正的後續段落之後才有鑑別力。 Co-Authored-By: Claude Opus 5 (1M context) --- test/issue-update.test.js | 60 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/test/issue-update.test.js b/test/issue-update.test.js index ddc6021..bd4819d 100644 --- a/test/issue-update.test.js +++ b/test/issue-update.test.js @@ -189,3 +189,63 @@ test('--dry-run 列出將送出的 PATCH,且不寫入', async (t) => { 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('## 整體驗收'), + '估算要留在關聯段落內', + ); +}); From 97d0afac383d24debfa6129699725143f1a65e8d Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 06:06:12 +0000 Subject: [PATCH 17/17] =?UTF-8?q?test(project-add):=20=E6=8A=8A=E3=80=8C?= =?UTF-8?q?=E4=B8=8D=E5=BB=BA=E7=AB=8B=E5=B0=88=E6=A1=88=E3=80=8D=E6=94=B9?= =?UTF-8?q?=E6=88=90=E9=A9=97=E5=BE=97=E5=87=BA=E6=9D=B1=E8=A5=BF=E7=9A=84?= =?UTF-8?q?=E6=96=B7=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原本的條件是「沒有非 GET 請求打到路徑含 project 的端點」,但 Gitea 的專案根本 沒有 API 路徑,這個條件恆真,壞掉的實作也會通過。改成斷言真正的寫入只有議題本身 那一次 PATCH、而且只帶 projects 欄位。 Co-Authored-By: Claude Opus 5 (1M context) --- test/project-add.test.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test/project-add.test.js b/test/project-add.test.js index ff3ec3c..43e6231 100644 --- a/test/project-add.test.js +++ b/test/project-add.test.js @@ -131,15 +131,20 @@ test('已經在目標看板上時不重發 PATCH', async (t) => { // ── 不建立專案 ───────────────────────────────────────────────────── -test('不建立專案:沒有任何非 GET 請求打到專案相關端點', async (t) => { +test('不建立專案:唯一的寫入是議題本身的 PATCH,且只帶 projects', async (t) => { + // 用「路徑含 project」當條件是空話——Gitea 的專案根本沒有 API 路徑 const stub = await withStub(t); await run(['--project', '開發看板'], stub); - const creations = stub.requests.filter( - (r) => r.method !== 'GET' && /project/i.test(r.path), + // 前置檢查的寫入權探針也是 PATCH,但它打在不存在的 /issues/0 上,不算真正的寫入 + const writes = stub.requests.filter( + (r) => r.method !== 'GET' && !r.path.endsWith('/issues/0'), ); - assert.deepEqual(creations, []); + 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 ─────────────────────────────────────────────────────