From bb886457fd7b1ccaa87cccb1d9babee4137118ef Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 08:23:20 +0000 Subject: [PATCH 01/15] =?UTF-8?q?feat(commit-split):=20=E6=8A=8A=E8=AE=8A?= =?UTF-8?q?=E6=9B=B4=E4=BE=9D=E9=A1=9E=E5=9E=8B=E5=88=86=E6=89=B9=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一個 commit 只裝一種類型:程式碼、測試、文件、雜項各自成批,reviewer 一次只看一件事, 日後 git log 也讀得懂。全部混成一顆「完成工作包」的巨大 commit,等於沒有歷史。 類型多半看得出來——測試檔就是 test、README 就是 docs——但 scripts/ 底下的改動是新功能 還是修 bug,只有做的人知道,所以那一批由 --type 指定。這張對照表是純字串規則,表格驅動。 scope 單檔用檔名(claim.test.js 的 scope 是 claim,不是 claim.test),多檔用 --scope 的 功能名。描述要有中文:日後回顧時看得懂的是中文,而夾雜英文的專有名詞本來就該保留原文。 --files 讓一次變更橫跨兩個功能時能分兩次跑;--body 讓工具產出的歷史與本 repo 既有的 commit 一樣說明得出「為什麼」。 列變更檔案刻意不用 git status --porcelain:它的前兩欄是狀態碼,而 runGit 會 trim 掉 輸出的前導空白,未 staged 的修改會少掉檔名的第一個字元。 --- scripts/commit-split.js | 202 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 scripts/commit-split.js diff --git a/scripts/commit-split.js b/scripts/commit-split.js new file mode 100644 index 0000000..7d1ad15 --- /dev/null +++ b/scripts/commit-split.js @@ -0,0 +1,202 @@ +#!/usr/bin/env node +/** + * 把工作區的變更依類型分批 commit。 + * + * 一個 commit 只裝一種類型:程式碼、測試、文件、雜項各自成批,reviewer 一次只看一件事, + * 日後 `git log` 也讀得懂。全部混成一顆「完成工作包」的巨大 commit,等於沒有歷史。 + * + * 類型多半看得出來——測試檔就是 test、README 就是 docs——但 `scripts/` 底下的改動 + * 是新功能還是修 bug,只有做的人知道,所以那一批由 `--type` 指定。 + * + * 訊息格式 `{類型}({scope}): {繁中描述}`,`--body` 接在首行之後說明「為什麼這樣做」。 + * scope 單檔用檔名、多檔用 `--scope` 的功能名。 + * 描述要用繁體中文——日後回顧時看得懂的是中文,不是當初隨手寫的英文。 + * + * 一次變更橫跨兩個不相干的功能時用 `--files` 分兩次跑:一顆 commit 的描述只說得清楚 + * 一件事,硬湊在一起就失去了分批的意義。 + * + * 用法: + * node scripts/commit-split.js --type feat --subject '<繁中描述>' + * [--scope <功能名>] [--body '<為什麼>'] [--files a.js,b.js] + * [--path <目標專案>] [--dry-run] + */ +import { existsSync } from 'node:fs'; +import { basename, join } from 'node:path'; +import { ScriptError, main, parseFlags, runGit } from './lib.js'; + +/** commit 訊息的類型。與既有 git 歷史一致,不另立新詞。 */ +const TYPES = ['feat', 'fix', 'refactor', 'test', 'docs', 'chore', 'perf', 'style']; + +/** + * 從檔案路徑看得出來的類型。由上往下比對,第一個命中的為準。 + * + * 只列「看路徑就能確定」的那幾種。`scripts/`、`prompts/`、`references/`、`templates/` + * 都是產品本身,是新增還是修正得由做的人說,所以不在這張表裡——它們吃 `--type`。 + */ +const BY_PATH = [ + { type: 'test', match: (path) => path.startsWith('test/') }, + { type: 'docs', match: (path) => /^[^/]+\.md$/.test(path) || path.startsWith('docs/') }, + { + type: 'chore', + match: (path) => + /^[^/]+$/.test(path) && !/\.md$/.test(path) && /^[.]|\.(json|ya?ml|toml|lock)$/.test(path), + }, + { type: 'chore', match: (path) => path.startsWith('.github/') || path.startsWith('.gitea/') }, +]; + +/** 分批的順序:先程式碼,再測試,最後周邊。git log 由新到舊讀起來才是「做了什麼、怎麼驗的」 */ +const ORDER = ['feat', 'fix', 'refactor', 'perf', 'style', 'test', 'docs', 'chore']; + +main(async () => { + const flags = parseFlags(process.argv.slice(2), { + required: ['type', 'subject'], + optional: ['scope', 'path', 'files', 'body'], + booleans: ['dry-run'], + }); + const path = flags.path ?? process.cwd(); + const type = parseType(flags.type); + const subject = parseSubject(flags.subject); + + if (!existsSync(join(path, '.git'))) { + throw new ScriptError('NOT_A_GIT_REPO', `${path} 不是 git repo;請用 --path 指向目標專案的根目錄`); + } + + const git = (...args) => runGit(args, { cwd: path }); + const { changed, untracked } = changedFiles(git); + if (changed.length === 0) { + throw new ScriptError('NOTHING_TO_COMMIT', `${path} 的工作區是乾淨的,沒有東西可以提交`); + } + + const commits = plan(selectFiles(changed, flags.files), type, flags.scope, subject, flags.body); + + if (flags['dry-run']) { + return { dryRun: true, path, commits }; + } + + for (const { message, files } of commits) { + // 只有未追蹤的檔案需要先 add:commit 帶 pathspec 不會把新檔案收進來, + // 但已追蹤的修改與刪除它自己處理得了。對已經被 git rm 掉的檔案再 add 一次只會報 + // 「找不到這個路徑」——那個檔案本來就已經不在工作區也不在 index 裡了。 + const toAdd = files.filter((file) => untracked.has(file)); + if (toAdd.length > 0) git('add', '--', ...toAdd); + git('commit', '-m', message, '--', ...files); + } + return { path, commits: commits.map(({ message, files }) => ({ message, files })) }; +}); + + +/** + * 列出工作區的變更檔案,含未追蹤與已刪除的。 + * + * 刻意不用 `git status --porcelain`:它每一行的前兩欄是狀態碼,未 staged 的修改是 + * 「空格 M」開頭,而 runGit 會 trim 掉輸出的前導空白——第一行的狀態欄會少一格, + * 切出來的檔名就少了第一個字元。改用兩個只印檔名的指令,不受 trim 影響。 + * + * 未追蹤的那一份要單獨留著:提交時只有它們需要先 add。 + * @returns {{changed: string[], untracked: Set}} + */ +function changedFiles(git) { + // 已追蹤的改動:staged 與未 staged 都算,刪除與改名(列為一刪一增)也在內 + const tracked = git('diff', '--name-only', 'HEAD').split('\n').filter((file) => file !== ''); + const untracked = git('ls-files', '--others', '--exclude-standard') + .split('\n') + .filter((file) => file !== ''); + + return { + changed: [...new Set([...tracked, ...untracked])].sort(), + untracked: new Set(untracked), + }; +} + +/** + * 挑出這一次要處理的檔案。沒給 `--files` 就是全部。 + * 指到沒有變更的檔案時報錯而不是略過——那多半是路徑打錯,默默少做一個檔案, + * 要等 PR 開出去才會有人發現。 + */ +function selectFiles(changed, files) { + if (files === undefined) return changed; + + const wanted = files.split(',').map((file) => file.trim()).filter((file) => file !== ''); + const missing = wanted.filter((file) => !changed.includes(file)); + if (missing.length > 0) { + throw new ScriptError( + 'FILE_NOT_CHANGED', + `--files 指到的這幾個檔案沒有變更:${missing.join('、')};請確認路徑(相對於 repo 根)`, + ); + } + return wanted.sort(); +} + +/** 把變更分成幾批,每批一個 commit */ +function plan(changed, type, scope, subject, body) { + const batches = new Map(); + for (const file of changed) { + const batchType = classify(file) ?? type; + if (!batches.has(batchType)) batches.set(batchType, []); + batches.get(batchType).push(file); + } + + return ORDER.filter((batchType) => batches.has(batchType)).map((batchType) => { + const files = batches.get(batchType); + const first = `${batchType}(${scopeOf(files, scope)}): ${subject}`; + // 同一次變更的每一批共用同一段說明:它們是同一件事的不同面向 + return { message: body === undefined ? first : `${first}\n\n${body.trim()}\n`, files }; + }); +} + +/** 看路徑就能確定的類型;看不出來時回 null,由 --type 決定 */ +function classify(file) { + return BY_PATH.find((rule) => rule.match(file))?.type ?? null; +} + +/** + * 這一批的 scope。單檔時用檔名本身——它已經說明了改的是什麼; + * 多檔時檔名沒有共同答案,得由呼叫端給一個功能名。 + */ +function scopeOf(files, scope) { + if (files.length === 1) return stemOf(files[0]); + if (scope === undefined) { + throw new ScriptError( + 'SCOPE_REQUIRED', + `有一批是多檔(${files.join('、')}),scope 沒有辦法從檔名推得,請用 --scope 給一個功能名`, + ); + } + return scope; +} + +/** + * 檔名去掉所有副檔名。`claim.test.js` 的 scope 是 `claim` 而不是 `claim.test`—— + * 既有歷史裡測試的 scope 就是它測的那個東西的名字。 + * 隱藏檔(`.gitignore`)的開頭那一點是名字的一部分,不是副檔名。 + */ +function stemOf(file) { + const name = basename(file); + const stem = name.startsWith('.') ? name.slice(1) : name; + return stem.split('.')[0] || stem; +} + +function parseType(value) { + if (!TYPES.includes(value)) { + throw new ScriptError('BAD_TYPE', `--type 需為 ${TYPES.join('/')} 其中一個,收到的是 ${value}`); + } + return value; +} + +/** + * 描述要有中文。這條規則擋的是「隨手寫一句英文」——日後回顧時看得懂的是中文, + * 而混用英文名詞(函式名、旗標名)本來就該保留原文,所以只要求含有中文,不是全中文。 + */ +function parseSubject(value) { + const subject = value.trim(); + if (subject === '') { + throw new ScriptError('BAD_SUBJECT', '--subject 不能是空的'); + } + if (!/[一-鿿]/.test(subject)) { + throw new ScriptError( + 'SUBJECT_NOT_CHINESE', + `--subject 要用繁體中文描述這次改了什麼,收到的是「${subject}」;` + + '夾雜英文的專有名詞沒問題,但整句英文日後回顧時讀起來最吃力', + ); + } + return subject; +} -- 2.53.0 From d6b44e0ba844a0546ff0d66f9a222ad4ca97f3a7 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 08:23:21 +0000 Subject: [PATCH 02/15] =?UTF-8?q?test(=E5=88=86=E6=89=B9=E6=8F=90=E4=BA=A4?= =?UTF-8?q?):=20=E6=8A=8A=E8=AE=8A=E6=9B=B4=E4=BE=9D=E9=A1=9E=E5=9E=8B?= =?UTF-8?q?=E5=88=86=E6=89=B9=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一個 commit 只裝一種類型:程式碼、測試、文件、雜項各自成批,reviewer 一次只看一件事, 日後 git log 也讀得懂。全部混成一顆「完成工作包」的巨大 commit,等於沒有歷史。 類型多半看得出來——測試檔就是 test、README 就是 docs——但 scripts/ 底下的改動是新功能 還是修 bug,只有做的人知道,所以那一批由 --type 指定。這張對照表是純字串規則,表格驅動。 scope 單檔用檔名(claim.test.js 的 scope 是 claim,不是 claim.test),多檔用 --scope 的 功能名。描述要有中文:日後回顧時看得懂的是中文,而夾雜英文的專有名詞本來就該保留原文。 --files 讓一次變更橫跨兩個功能時能分兩次跑;--body 讓工具產出的歷史與本 repo 既有的 commit 一樣說明得出「為什麼」。 列變更檔案刻意不用 git status --porcelain:它的前兩欄是狀態碼,而 runGit 會 trim 掉 輸出的前導空白,未 staged 的修改會少掉檔名的第一個字元。 --- test/commit-split.test.js | 354 ++++++++++++++++++++++++++++++++++++++ test/helpers/temp-repo.js | 5 +- 2 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 test/commit-split.test.js diff --git a/test/commit-split.test.js b/test/commit-split.test.js new file mode 100644 index 0000000..31de5be --- /dev/null +++ b/test/commit-split.test.js @@ -0,0 +1,354 @@ +/** + * 把變更分批 commit。 + * + * 兩件事各自要驗: + * 1. **類型分類**是純字串規則,表格驅動——它決定 git 歷史讀不讀得懂, + * 而錯了之後要改歷史才修得回來。 + * 2. **分批的界線**:一個 commit 只裝一種類型,程式碼與測試不混在一起, + * reviewer 才能一次只看一件事。 + * + * git 不做 mock:在臨時 repo 上跑真的 git 比假的 git 可信,成本也低。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { runScript } from './helpers/run-script.js'; +import { makeTempRepo } from './helpers/temp-repo.js'; + +function withRepo(t) { + const repo = makeTempRepo(); + t.after(() => repo.cleanup()); + return repo; +} + +/** 在 repo 裡寫幾個檔案(含目錄),模擬一次實作留下的變更 */ +function write(repo, ...paths) { + for (const path of paths) { + const full = join(repo.dir, path); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, `// ${path}\n`); + } +} + +const run = (repo, args) => runScript('commit-split.js', ['--path', repo.dir, ...args]); + +/** 初始 commit 之後新增的 commit 訊息首行,由舊到新 */ +const subjects = (repo) => + repo.git('log', '--format=%s', '--reverse').split('\n').filter((line) => line !== '').slice(1); + +// ── 類型分類:表格驅動 ───────────────────────────────────────────── + +const CLASSIFY = [ + { path: 'test/claim.test.js', type: 'test', why: '測試檔' }, + { path: 'test/helpers/stub-gitea.js', type: 'test', why: '測試用的 helper 也算測試' }, + { path: 'README.md', type: 'docs', why: '根目錄的說明文件' }, + { path: 'AGENTS.md', type: 'docs', why: '同上' }, + { path: 'package.json', type: 'chore', why: '專案設定' }, + { path: '.gitignore', type: 'chore', why: '同上' }, + { path: 'scripts/claim.js', type: null, why: '看不出是新功能還是修 bug,要由呼叫端指定' }, + { path: 'prompts/sdlc-feat.md', type: null, why: '流程正本是產品的一部分,同上' }, + { path: 'references/coding-standards.md', type: null, why: '規則正本同上' }, + { path: 'templates/work-package-issue.md', type: null, why: '輸出模板同上' }, +]; + +for (const { path, type, why } of CLASSIFY) { + test(`分類:${path} → ${type ?? '由 --type 決定'}(${why})`, async (t) => { + const repo = withRepo(t); + write(repo, path); + + const { code, json } = await run(repo, ['--type', 'feat', '--subject', '做了一件事']); + + assert.equal(code, 0, JSON.stringify(json)); + assert.equal(json.data.commits.length, 1); + assert.match(json.data.commits[0].message, new RegExp(`^${type ?? 'feat'}\\(`)); + }); +} + +// ── 分批:一個 commit 只裝一種類型 ───────────────────────────────── + +test('程式碼與測試分成兩個 commit,不混在一起', async (t) => { + const repo = withRepo(t); + write(repo, 'scripts/claim.js', 'test/claim.test.js'); + + const { code, json } = await run(repo, ['--type', 'feat', '--subject', '領取工作包']); + + assert.equal(code, 0, JSON.stringify(json)); + assert.deepEqual(subjects(repo), [ + 'feat(claim): 領取工作包', + 'test(claim): 領取工作包', + ]); +}); + +test('四種類型都出現時分成四個 commit,順序為先程式碼後周邊', async (t) => { + const repo = withRepo(t); + write(repo, 'scripts/claim.js', 'test/claim.test.js', 'README.md', 'package.json'); + + const { json } = await run(repo, ['--type', 'feat', '--scope', '領取', '--subject', '領取工作包']); + + assert.deepEqual(json.data.commits.map((c) => c.message), [ + 'feat(claim): 領取工作包', + 'test(claim): 領取工作包', + 'docs(README): 領取工作包', + 'chore(package): 領取工作包', + ]); +}); + +test('每個 commit 只含它自己那一批檔案', async (t) => { + const repo = withRepo(t); + write(repo, 'scripts/claim.js', 'test/claim.test.js'); + + await run(repo, ['--type', 'feat', '--subject', '領取工作包']); + + const firstFiles = repo.git('show', '--name-only', '--format=', 'HEAD~1').split('\n').filter(Boolean); + const secondFiles = repo.git('show', '--name-only', '--format=', 'HEAD').split('\n').filter(Boolean); + assert.deepEqual(firstFiles, ['scripts/claim.js']); + assert.deepEqual(secondFiles, ['test/claim.test.js']); +}); + +test('工作區在跑完之後是乾淨的:沒有檔案被漏掉', async (t) => { + const repo = withRepo(t); + write(repo, 'scripts/claim.js', 'test/claim.test.js', 'README.md', 'package.json'); + + await run(repo, ['--type', 'feat', '--scope', '領取', '--subject', '領取工作包']); + + assert.equal(repo.git('status', '--porcelain'), ''); +}); + +// ── scope:單檔用檔名,多檔用功能名 ─────────────────────────────── + +test('一批只有一個檔案時,scope 是那個檔名(去掉目錄與副檔名)', async (t) => { + const repo = withRepo(t); + write(repo, 'scripts/branch-prep.js'); + + const { json } = await run(repo, ['--type', 'feat', '--subject', '備妥分支']); + + assert.equal(json.data.commits[0].message, 'feat(branch-prep): 備妥分支'); +}); + +test('一批有多個檔案時,scope 是 --scope 給的功能名', async (t) => { + const repo = withRepo(t); + write(repo, 'scripts/claim.js', 'scripts/branch-prep.js'); + + const { json } = await run(repo, ['--type', 'feat', '--scope', '領取與分支', '--subject', '備妥開工']); + + assert.equal(json.data.commits[0].message, 'feat(領取與分支): 備妥開工'); +}); + +test('多檔卻沒給 --scope 時擋下,並說明什麼時候要給', async (t) => { + const repo = withRepo(t); + write(repo, 'scripts/claim.js', 'scripts/branch-prep.js'); + + const { code, json } = await run(repo, ['--type', 'feat', '--subject', '備妥開工']); + + assert.equal(code, 1); + assert.equal(json.error.code, 'SCOPE_REQUIRED'); + assert.match(json.error.message, /多檔/); + assert.equal(repo.git('status', '--porcelain') === '', false, '擋下來就不該已經提交掉'); +}); + +test('--scope 只在多檔那幾批生效,單檔那批仍用檔名', async (t) => { + const repo = withRepo(t); + write(repo, 'scripts/claim.js', 'scripts/branch-prep.js', 'test/claim.test.js'); + + const { json } = await run(repo, ['--type', 'feat', '--scope', '領取與分支', '--subject', '備妥開工']); + + assert.deepEqual(json.data.commits.map((c) => c.message), [ + 'feat(領取與分支): 備妥開工', + 'test(claim): 備妥開工', + ]); +}); + +// ── --files:一次只處理一個功能 ─────────────────────────────────── + +test('--files 只提交指定的那幾個檔案,其餘原封不動留著', async (t) => { + // 正本要求「一次變更橫跨兩個不相干的功能時分兩次跑」,那就得有辦法只處理一部分 + const repo = withRepo(t); + write(repo, 'scripts/claim.js', 'scripts/branch-prep.js'); + + const { code, json } = await run(repo, [ + '--type', 'feat', '--subject', '領取工作包', '--files', 'scripts/claim.js', + ]); + + assert.equal(code, 0, JSON.stringify(json)); + assert.deepEqual(json.data.commits.map((c) => c.message), ['feat(claim): 領取工作包']); + assert.equal( + repo.git('status', '--porcelain').includes('branch-prep.js'), + true, + '沒被指定的檔案要留在工作區', + ); +}); + +test('--files 指定多個檔案時照樣依類型分批', async (t) => { + const repo = withRepo(t); + write(repo, 'scripts/claim.js', 'test/claim.test.js', 'scripts/branch-prep.js'); + + const { json } = await run(repo, [ + '--type', 'feat', '--subject', '領取工作包', + '--files', 'scripts/claim.js,test/claim.test.js', + ]); + + assert.deepEqual(json.data.commits.map((c) => c.message), [ + 'feat(claim): 領取工作包', + 'test(claim): 領取工作包', + ]); +}); + +test('--files 指到沒有變更的檔案時擋下,不默默少做', async (t) => { + const repo = withRepo(t); + write(repo, 'scripts/claim.js'); + + const { code, json } = await run(repo, [ + '--type', 'feat', '--subject', '領取工作包', '--files', 'scripts/claim.js,scripts/沒改過.js', + ]); + + assert.equal(code, 1); + assert.equal(json.error.code, 'FILE_NOT_CHANGED'); + assert.match(json.error.message, /沒改過/); +}); + +// ── 訊息格式 ─────────────────────────────────────────────────────── + +test('描述要用繁體中文,純英文的描述會被擋下', async (t) => { + const repo = withRepo(t); + write(repo, 'scripts/claim.js'); + + const { code, json } = await run(repo, ['--type', 'feat', '--subject', 'claim the work package']); + + assert.equal(code, 1); + assert.equal(json.error.code, 'SUBJECT_NOT_CHINESE'); + assert.match(json.error.message, /繁體中文|中文/); +}); + +test('描述夾雜英文是可以的,只要有中文', async (t) => { + const repo = withRepo(t); + write(repo, 'scripts/claim.js'); + + const { code, json } = await run(repo, ['--type', 'feat', '--subject', '讓 claim 擋住他人已認領的工作包']); + + assert.equal(code, 0, JSON.stringify(json)); + assert.equal(json.data.commits[0].message, 'feat(claim): 讓 claim 擋住他人已認領的工作包'); +}); + +test('--type 不是既定分類時擋下,並列出可用的', async (t) => { + const repo = withRepo(t); + write(repo, 'scripts/claim.js'); + + const { json } = await run(repo, ['--type', 'feature', '--subject', '做了一件事']); + + assert.equal(json.error.code, 'BAD_TYPE'); + assert.match(json.error.message, /feat/); +}); + +// ── 訊息本體 ─────────────────────────────────────────────────────── + +test('--body 接在首行之後,中間空一行', async (t) => { + // 本 repo 的每一顆 commit 都說明「為什麼這樣做」,工具產出的歷史不該只有首行 + const repo = withRepo(t); + write(repo, 'scripts/claim.js'); + + const { code, json } = await run(repo, [ + '--type', 'feat', '--subject', '領取工作包', + '--body', '鎖用 assignee 加標籤,不用碼錶——Gitea 只讀得到自己的錶。', + ]); + + assert.equal(code, 0, JSON.stringify(json)); + assert.equal( + repo.git('log', '-1', '--format=%B').trim(), + 'feat(claim): 領取工作包\n\n鎖用 assignee 加標籤,不用碼錶——Gitea 只讀得到自己的錶。', + ); +}); + +test('同一批變更的每一顆 commit 共用同一段說明', async (t) => { + const repo = withRepo(t); + write(repo, 'scripts/claim.js', 'test/claim.test.js'); + + await run(repo, ['--type', 'feat', '--subject', '領取工作包', '--body', '說明為什麼。']); + + for (const ref of ['HEAD', 'HEAD~1']) { + assert.match(repo.git('log', '-1', '--format=%b', ref), /說明為什麼。/); + } +}); + +test('沒給 --body 時訊息就只有首行,不補空行', async (t) => { + const repo = withRepo(t); + write(repo, 'scripts/claim.js'); + + await run(repo, ['--type', 'feat', '--subject', '領取工作包']); + + assert.equal(repo.git('log', '-1', '--format=%B').trim(), 'feat(claim): 領取工作包'); +}); + +// ── 沒有東西可提交 ───────────────────────────────────────────────── + +test('工作區乾淨時回可區分的錯誤碼,不做出一顆空 commit', async (t) => { + const repo = withRepo(t); + + const { code, json } = await run(repo, ['--type', 'feat', '--subject', '什麼都沒改']); + + assert.equal(code, 1); + assert.equal(json.error.code, 'NOTHING_TO_COMMIT'); +}); + +// ── 刪除與改名 ───────────────────────────────────────────────────── + +test('被刪掉的檔案也照樣分類、照樣進 commit', async (t) => { + const repo = withRepo(t); + write(repo, 'scripts/old.js'); + repo.git('add', '-A'); + repo.git('commit', '-qm', '先有這個檔案'); + repo.git('rm', '-q', 'scripts/old.js'); + + const { code, json } = await run(repo, ['--type', 'refactor', '--subject', '移除不再使用的腳本']); + + assert.equal(code, 0, JSON.stringify(json)); + assert.equal(json.data.commits[0].message, 'refactor(old): 移除不再使用的腳本'); + assert.equal(repo.git('status', '--porcelain'), ''); +}); + +// ── --dry-run ───────────────────────────────────────────────────── + +test('--dry-run 印出將建立的 commit 與各自的檔案,但不提交', async (t) => { + const repo = withRepo(t); + write(repo, 'scripts/claim.js', 'test/claim.test.js'); + const before = repo.git('rev-parse', 'HEAD'); + + const { code, json } = await run(repo, ['--type', 'feat', '--subject', '領取工作包', '--dry-run']); + + assert.equal(code, 0); + assert.equal(json.data.dryRun, true); + assert.deepEqual(json.data.commits, [ + { message: 'feat(claim): 領取工作包', files: ['scripts/claim.js'] }, + { message: 'test(claim): 領取工作包', files: ['test/claim.test.js'] }, + ]); + assert.equal(repo.git('rev-parse', 'HEAD'), before, '試跑不該產生 commit'); + assert.equal(repo.git('status', '--porcelain') === '', false, '變更要原封不動留著'); +}); + +test('--dry-run 在多檔缺 --scope 時一樣報錯,不會等到實跑才發現', async (t) => { + const repo = withRepo(t); + write(repo, 'scripts/claim.js', 'scripts/branch-prep.js'); + + const { json } = await run(repo, ['--type', 'feat', '--subject', '備妥開工', '--dry-run']); + + assert.equal(json.error.code, 'SCOPE_REQUIRED'); +}); + +// ── 路徑 ─────────────────────────────────────────────────────────── + +test('--path 不是 git repo 時回可區分的錯誤碼', async (t) => { + const { json } = await runScript('commit-split.js', [ + '--path', '/', '--type', 'feat', '--subject', '做了一件事', + ]); + + assert.equal(json.error.code, 'NOT_A_GIT_REPO'); +}); + +test('git 自己的訊息不漏到 stderr', async (t) => { + const repo = withRepo(t); + write(repo, 'scripts/claim.js'); + + const { stderr } = await run(repo, ['--type', 'feat', '--subject', '領取工作包']); + + assert.equal(stderr, ''); +}); diff --git a/test/helpers/temp-repo.js b/test/helpers/temp-repo.js index e7235ca..a5933a6 100644 --- a/test/helpers/temp-repo.js +++ b/test/helpers/temp-repo.js @@ -8,7 +8,8 @@ import { join } from 'node:path'; import { tmpRoot } from './run-script.js'; /** - * @returns {{dir: string, cleanup: Function}} dir 為已有一顆 commit 的 git repo + * @returns {{dir: string, git: Function, cleanup: Function}} dir 為已有一顆 commit 的 git repo, + * git 為綁在它身上的執行器(與 makeTempRepoWithRemote 對稱) */ export function makeTempRepo() { mkdirSync(tmpRoot, { recursive: true }); @@ -18,7 +19,7 @@ export function makeTempRepo() { git('init', '-q', '-b', 'master'); seed(git, dir, 'tester', 'tester@example.com'); - return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; + return { dir, git, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; } /** -- 2.53.0 From b5214ed0f1fc097262551eabde035b01c7ffc4e6 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 08:23:21 +0000 Subject: [PATCH 03/15] =?UTF-8?q?feat(pr-create):=20=E9=96=8B=E7=AB=8B=20P?= =?UTF-8?q?R=20=E4=B8=A6=E5=81=9C=E9=8C=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 標題等同分支名:reviewer 在列表上看到的就是分支,兩者對不上會找錯 PR。 描述的段落固定且順序固定,缺一段或順序不對就擋下,不自動補——補出來的段落是編的, 而 reviewer 會把它當成真的。 「測試結果」另外驗一次它不是空話。那一段是 reviewer 唯一能判斷「這東西真的跑過嗎」的 依據,寫「已測試通過」等於沒寫。判斷刻意很窄,只擋「整段只有一行,而那一行是已知的 偷懶寫法」——這一關要擋的是明顯沒跑過就交差,不是去評價別人的測試寫得夠不夠好。 沒有自動化測試時,寫得出可重現的手動驗證步驟就放行。 停錶排在 PR 開出去之後,而且只在 PR 真的建立了才停:工時要記在真的有做事的那段時間上。 錶本來就沒在跑不算失敗(Gitea 對此回 500)——PR 已經開出去了,把整件事報成失敗只會讓人 以為 PR 沒開成而重跑一次。 --- scripts/pr-create.js | 183 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 scripts/pr-create.js diff --git a/scripts/pr-create.js b/scripts/pr-create.js new file mode 100644 index 0000000..25de2f0 --- /dev/null +++ b/scripts/pr-create.js @@ -0,0 +1,183 @@ +#!/usr/bin/env node +/** + * 開立 PR,然後停錶。 + * + * 標題等同分支名:reviewer 在列表上看到的就是分支,兩者對不上會找錯 PR。 + * + * 描述的段落固定且順序固定——reviewer 每次都在同一個位置找到要找的資訊。缺一段或順序 + * 不對就擋下,不自動補:補出來的段落是編的,而 reviewer 會把它當成真的。 + * + * 「測試結果」另外驗一次它不是空話。那一段是 reviewer 唯一能判斷「這東西真的跑過嗎」 + * 的依據,寫「已測試通過」等於沒寫。沒有自動化測試時,寫可重現的手動驗證步驟也算數。 + * + * 停錶排在 PR 開出去之後,而且只在 PR 真的建立了才停:工時要記在真的有做事的那段 + * 時間上。錶本來就沒在跑不算失敗——PR 已經開出去了,不該把整件事報成失敗。 + * + * 用法: + * node scripts/pr-create.js --repo owner/name --head <分支> --body-file <描述檔> + * [--base master] [--index 13] [--host <網址>] [--dry-run] + */ +import { existsSync, readFileSync } from 'node:fs'; +import { + ScriptError, + expectOk, + giteaRequest, + main, + parseFlags, + parseIndex, + parseRepo, + preflight, + resolveLogin, +} from './lib.js'; + +/** 描述的固定段落,順序即 reviewer 閱讀的順序 */ +const SECTIONS = [ + '摘要', + '需求議題', + '工作包議題', + '變更內容', + '設計重點', + '解決的問題', + '影響的功能', + '測試結果', +]; + +/** + * 「測試結果」裡等於沒寫的那幾句。 + * 不是窮舉,是擋住最常見的偷懶寫法——真的跑過的話,貼輸出比打這幾個字還快。 + */ +const EMPTY_TALK = new Set([ + '無', + '沒有', + 'N/A', + 'n/a', + '已測試', + '已測試通過', + '測試通過', + '測試皆通過', + '測試皆已通過', + '全部通過', + '全數通過', + '皆通過', + 'ok', + 'OK', +]); + +main(async () => { + const flags = parseFlags(process.argv.slice(2), { + required: ['repo', 'head', 'body-file'], + optional: ['base', 'index', 'host'], + booleans: ['dry-run'], + }); + const repo = parseRepo(flags.repo); + const head = flags.head; + const base = flags.base ?? 'master'; + const index = flags.index === undefined ? null : parseIndex(flags.index); + const body = readBody(flags['body-file']); + + // 描述先驗完再談寫入:不合格的描述不該等到實跑才發現 + checkSections(body); + checkTestResult(body); + + const pullsPath = `/repos/${repo}/pulls`; + const stopPath = index === null ? null : `/repos/${repo}/issues/${index}/stopwatch/stop`; + const payload = { title: head, head, base, body }; + + if (flags['dry-run']) { + const requests = [{ method: 'POST', path: pullsPath, body: payload }]; + if (stopPath) requests.push({ method: 'POST', path: stopPath, body: {} }); + return { dryRun: true, repo, head, base, title: head, requests }; + } + + const login = resolveLogin({ host: flags.host }); + await preflight(login, repo); + + const pull = expectOk( + await giteaRequest(login, 'POST', pullsPath, { body: payload }), + `POST ${pullsPath}`, + ); + + // 錶只在 PR 真的開出去之後才停 + const stopped = stopPath === null ? false : await stopStopwatch(login, stopPath); + + return { + repo, + index, + title: pull.title, + url: pull.html_url, + number: pull.number, + head, + base, + 碼錶已停: stopped, + ...(stopped || stopPath === null + ? {} + : { note: '碼錶本來就沒在這顆議題上運轉,PR 已經開出去了,這一步略過。' }), + }; +}); + + +function readBody(path) { + if (!existsSync(path)) { + throw new ScriptError('BODY_FILE_NOT_FOUND', `找不到描述檔 ${path}`); + } + return readFileSync(path, 'utf8'); +} + +/** 八個段落一個都不能少,而且順序要與 SECTIONS 一致 */ +function checkSections(body) { + const found = [...body.matchAll(/^##\s+(.+?)\s*$/gm)].map((match) => match[1]); + + const missing = SECTIONS.filter((section) => !found.includes(section)); + if (missing.length > 0) { + throw new ScriptError( + 'MISSING_SECTION', + `PR 描述缺少這幾段:${missing.join('、')};` + + `固定的段落順序為 ${SECTIONS.join('/')},reviewer 每次都在同一個位置找同一件事`, + ); + } + + const order = found.filter((section) => SECTIONS.includes(section)); + if (order.join('\n') !== SECTIONS.join('\n')) { + throw new ScriptError( + 'SECTION_ORDER', + `PR 描述的段落順序不對:收到的是 ${order.join('/')},應為 ${SECTIONS.join('/')}`, + ); + } +} + +/** + * 「測試結果」不能是空話。 + * 判斷很窄——只看「整段只有一行,而那一行是已知的偷懶寫法」。窄是刻意的: + * 這一關要擋的是明顯沒跑過就交差,不是去評價別人的測試寫得夠不夠好。 + */ +function checkTestResult(body) { + const section = body.slice(body.indexOf('## 測試結果')); + const content = section + .split('\n') + .slice(1) + .join('\n') + .trim(); + + const lines = content.split('\n').filter((line) => line.trim() !== ''); + const onlyLine = lines.length === 1 ? lines[0].trim().replace(/[。..]$/, '') : null; + + if (content === '' || (onlyLine !== null && EMPTY_TALK.has(onlyLine))) { + throw new ScriptError( + 'EMPTY_TEST_RESULT', + '「測試結果」要放實際跑過的輸出;沒有自動化測試時,寫出 reviewer 自己能重現的' + + '手動驗證步驟。「已測試通過」這種寫法看不出跑過什麼,等於沒寫', + ); + } +} + +/** + * 停錶。錶沒在跑時 Gitea 回 500,那不算失敗——PR 已經開出去了, + * 把整件事報成失敗只會讓人以為 PR 沒開成而重跑一次。 + */ +async function stopStopwatch(login, path) { + const response = await giteaRequest(login, 'POST', path, { body: {} }); + if (response.status >= 200 && response.status < 300) return true; + if (response.status === 500 || response.status === 409) return false; + + return expectOk(response, `POST ${path}`) !== undefined; +} -- 2.53.0 From 958b1f85e8905128ab4ae2410808f30bc4aa492e Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 08:23:22 +0000 Subject: [PATCH 04/15] =?UTF-8?q?test(pr-create):=20=E9=96=8B=E7=AB=8B=20P?= =?UTF-8?q?R=20=E4=B8=A6=E5=81=9C=E9=8C=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 標題等同分支名:reviewer 在列表上看到的就是分支,兩者對不上會找錯 PR。 描述的段落固定且順序固定,缺一段或順序不對就擋下,不自動補——補出來的段落是編的, 而 reviewer 會把它當成真的。 「測試結果」另外驗一次它不是空話。那一段是 reviewer 唯一能判斷「這東西真的跑過嗎」的 依據,寫「已測試通過」等於沒寫。判斷刻意很窄,只擋「整段只有一行,而那一行是已知的 偷懶寫法」——這一關要擋的是明顯沒跑過就交差,不是去評價別人的測試寫得夠不夠好。 沒有自動化測試時,寫得出可重現的手動驗證步驟就放行。 停錶排在 PR 開出去之後,而且只在 PR 真的建立了才停:工時要記在真的有做事的那段時間上。 錶本來就沒在跑不算失敗(Gitea 對此回 500)——PR 已經開出去了,把整件事報成失敗只會讓人 以為 PR 沒開成而重跑一次。 --- test/pr-create.test.js | 301 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 test/pr-create.test.js diff --git a/test/pr-create.test.js b/test/pr-create.test.js new file mode 100644 index 0000000..f8421f1 --- /dev/null +++ b/test/pr-create.test.js @@ -0,0 +1,301 @@ +/** + * 開立 PR 並停錶。 + * + * 三件事要驗: + * 1. **標題等同分支名**——reviewer 在列表上看到的就是分支,兩者對不上會找錯 PR。 + * 2. **描述的七段都在**,而且「測試結果」不是空話。這一段是 reviewer 唯一能判斷 + * 「這東西真的跑過嗎」的依據,寫「已測試通過」等於沒寫。 + * 3. **PR 開完才停錶**,而且開失敗時錶不能停——工時要記在真的有做事的那段時間上。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { runScript, tmpRoot } from './helpers/run-script.js'; +import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js'; + +const REPO = 'plugins/tea-sdlc'; +const HEAD = 'feat/commit-split-and-pr/main'; +const INDEX = 13; + +/** 一份七段俱全的描述 */ +const BODY = `## 摘要 + +工作包做完之後,變更被整理成可讀的歷史,PR 開出來,碼錶停下。 + +## 需求議題 + +#1 + +## 工作包議題 + +#13 + +## 變更內容 + +新增 commit-split 與 pr-create 兩支腳本。 + +## 設計重點 + +分批的界線是類型,一個 commit 只裝一種。 + +## 解決的問題 + +巨大的單一 commit 等於沒有歷史。 + +## 影響的功能 + +sdlc-feat 的第三段。 + +## 測試結果 + +\`\`\` +ℹ tests 527 +ℹ pass 527 +ℹ fail 0 +\`\`\` +`; + +/** 把描述寫成檔案,回傳路徑 */ +function bodyFile(name, content) { + mkdirSync(tmpRoot, { recursive: true }); + const path = join(tmpRoot, `pr-body-${name}-${process.hrtime.bigint()}.md`); + writeFileSync(path, content); + return path; +} + +function routes(overrides = {}) { + return healthyRoutes(REPO, { + [`POST /api/v1/repos/${REPO}/pulls`]: (req) => ({ + status: 201, + body: { number: 99, title: req.body.title, html_url: `https://gitea.jsc.idv.tw/${REPO}/pulls/99` }, + }), + [`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`]: { status: 201, body: {} }, + ...overrides, + }); +} + +const withStub = (t, overrides = {}) => withStubGitea(t, routes(overrides)); + +const run = (args, stub) => + runScript('pr-create.js', ['--repo', REPO, '--head', HEAD, ...args], { env: envFor(stub) }); + +const posts = (stub) => + stub.requests.filter((r) => r.method === 'POST').map((r) => r.path); + +// ── 標題與描述 ───────────────────────────────────────────────────── + +test('PR 標題等同分支名', async (t) => { + const stub = await withStub(t); + const file = bodyFile('full', BODY); + + const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub); + + assert.equal(code, 0, JSON.stringify(json)); + const pull = stub.requests.find((r) => r.path.endsWith('/pulls')); + assert.equal(pull.body.title, HEAD); + assert.equal(json.data.title, HEAD); +}); + +test('描述原樣送出,一個字都不改寫', async (t) => { + const stub = await withStub(t); + const file = bodyFile('verbatim', BODY); + + await run(['--body-file', file, '--index', String(INDEX)], stub); + + const pull = stub.requests.find((r) => r.path.endsWith('/pulls')); + assert.equal(pull.body.body, BODY); +}); + +test('base 預設為 master,也可以指定', async (t) => { + const stub = await withStub(t); + const file = bodyFile('base', BODY); + + await run(['--body-file', file, '--index', String(INDEX)], stub); + const first = stub.requests.find((r) => r.path.endsWith('/pulls')); + assert.equal(first.body.base, 'master'); + + const stub2 = await withStub(t); + const file2 = bodyFile('base2', BODY); + await run(['--body-file', file2, '--index', String(INDEX), '--base', 'develop'], stub2); + assert.equal(stub2.requests.find((r) => r.path.endsWith('/pulls')).body.base, 'develop'); +}); + +// ── 七段:少一段就擋 ─────────────────────────────────────────────── + +const SECTIONS = [ + '摘要', '需求議題', '工作包議題', '變更內容', + '設計重點', '解決的問題', '影響的功能', '測試結果', +]; + +for (const missing of SECTIONS) { + test(`描述缺少「${missing}」時擋下,並指名缺的是哪一段`, async (t) => { + const stub = await withStub(t); + const body = BODY.split(/^## /m) + .filter((part) => !part.startsWith(missing)) + .join('## '); + const file = bodyFile(`missing-${missing}`, body); + + const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub); + + assert.equal(code, 1); + assert.equal(json.error.code, 'MISSING_SECTION'); + assert.match(json.error.message, new RegExp(missing)); + assert.deepEqual(posts(stub), [], '描述不合格就不該開 PR'); + }); +} + +test('段落順序不對時也擋下:reviewer 每次要在同一個位置找到同一件事', async (t) => { + const stub = await withStub(t); + const swapped = BODY.replace( + /## 設計重點([\s\S]*?)## 解決的問題([\s\S]*?)## 影響的功能/, + '## 解決的問題$2## 設計重點$1## 影響的功能', + ); + const file = bodyFile('order', swapped); + + const { json } = await run(['--body-file', file, '--index', String(INDEX)], stub); + + assert.equal(json.error.code, 'SECTION_ORDER'); +}); + +// ── 測試結果不能是空話 ───────────────────────────────────────────── + +const EMPTY_TALK = ['已測試通過', '測試通過', '全部通過', '測試皆已通過', '無']; + +for (const talk of EMPTY_TALK) { + test(`測試結果只寫「${talk}」時擋下`, async (t) => { + const stub = await withStub(t); + const body = BODY.replace(/## 測試結果[\s\S]*$/, `## 測試結果\n\n${talk}\n`); + const file = bodyFile(`talk-${talk}`, body); + + const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub); + + assert.equal(code, 1); + assert.equal(json.error.code, 'EMPTY_TEST_RESULT'); + assert.match(json.error.message, /實際跑過|手動驗證/); + assert.deepEqual(posts(stub), []); + }); +} + +test('測試結果是空的時候擋下', async (t) => { + const stub = await withStub(t); + const file = bodyFile('empty', BODY.replace(/## 測試結果[\s\S]*$/, '## 測試結果\n\n')); + + const { json } = await run(['--body-file', file, '--index', String(INDEX)], stub); + + assert.equal(json.error.code, 'EMPTY_TEST_RESULT'); +}); + +test('沒有自動化測試時,寫得出可重現的手動驗證步驟就放行', async (t) => { + const stub = await withStub(t); + const manual = BODY.replace( + /## 測試結果[\s\S]*$/, + '## 測試結果\n\n本工作包無自動化測試,手動驗證步驟:\n\n' + + '1. 執行 `node scripts/pr-create.js --dry-run`\n' + + '2. 確認印出的標題等於分支名\n', + ); + const file = bodyFile('manual', manual); + + const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub); + + assert.equal(code, 0, JSON.stringify(json)); +}); + +// ── 停錶 ─────────────────────────────────────────────────────────── + +test('PR 開完之後才停錶,順序不能反', async (t) => { + const stub = await withStub(t); + const file = bodyFile('stop', BODY); + + await run(['--body-file', file, '--index', String(INDEX)], stub); + + assert.deepEqual(posts(stub), [ + `/api/v1/repos/${REPO}/pulls`, + `/api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`, + ]); +}); + +test('PR 開失敗時不停錶:工時要記在真的有做事的那段時間上', async (t) => { + const stub = await withStub(t, { + [`POST /api/v1/repos/${REPO}/pulls`]: { status: 422, body: { message: 'pull request already exists' } }, + }); + const file = bodyFile('fail', BODY); + + const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub); + + assert.equal(code, 1); + assert.equal( + posts(stub).some((path) => path.endsWith('/stopwatch/stop')), + false, + 'PR 沒開成就不該停錶', + ); + assert.match(json.error.message, /422|already exists/); +}); + +test('錶本來就沒在跑時不算失敗:PR 已經開出去了', async (t) => { + // Gitea 對「沒有碼錶在跑」回 500;這時 PR 已經建立,不該把整件事報成失敗 + const stub = await withStub(t, { + [`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`]: { + status: 500, + body: { message: 'cannot stop a non existent stopwatch' }, + }, + }); + const file = bodyFile('nowatch', BODY); + + const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub); + + assert.equal(code, 0, JSON.stringify(json)); + assert.equal(json.data.碼錶已停, false); + assert.match(json.data.note ?? '', /碼錶/); +}); + +test('沒給 --index 就不停錶,也不報錯', async (t) => { + const stub = await withStub(t); + const file = bodyFile('noindex', BODY); + + const { code, json } = await run(['--body-file', file], stub); + + assert.equal(code, 0, JSON.stringify(json)); + assert.deepEqual(posts(stub), [`/api/v1/repos/${REPO}/pulls`]); + assert.equal(json.data.碼錶已停, false); +}); + +// ── 輸入 ─────────────────────────────────────────────────────────── + +test('描述檔不存在時回可區分的錯誤碼', async (t) => { + const stub = await withStub(t); + + const { json } = await run(['--body-file', join(tmpRoot, '不存在的檔案.md')], stub); + + assert.equal(json.error.code, 'BODY_FILE_NOT_FOUND'); +}); + +// ── --dry-run ───────────────────────────────────────────────────── + +test('--dry-run 印出將建立的 PR 與將停的錶,但不碰 Gitea', async (t) => { + const stub = await withStub(t); + const file = bodyFile('dry', BODY); + + const { code, json } = await run(['--body-file', file, '--index', String(INDEX), '--dry-run'], stub); + + assert.equal(code, 0); + assert.equal(json.data.dryRun, true); + assert.deepEqual( + json.data.requests.map((r) => `${r.method} ${r.path}`), + [ + `POST /repos/${REPO}/pulls`, + `POST /repos/${REPO}/issues/${INDEX}/stopwatch/stop`, + ], + ); + assert.equal(json.data.requests[0].body.title, HEAD, '試跑要看得到標題長什麼樣'); + assert.equal(stub.requests.length, 0); +}); + +test('--dry-run 照樣驗描述:不合格的描述不該等到實跑才發現', async (t) => { + const stub = await withStub(t); + const file = bodyFile('dry-bad', BODY.replace('## 測試結果', '## 測試結論')); + + const { json } = await run(['--body-file', file, '--dry-run'], stub); + + assert.equal(json.error.code, 'MISSING_SECTION'); +}); -- 2.53.0 From 0b728ca2705483696d74ddbbf35ce3274a3eb010 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 08:23:22 +0000 Subject: [PATCH 05/15] =?UTF-8?q?feat(sdlc-feat):=20=E5=8A=A0=E5=85=A5?= =?UTF-8?q?=E7=AC=AC=E4=B8=89=E6=AE=B5=E3=80=8C=E6=8F=90=E4=BA=A4=E8=88=87?= =?UTF-8?q?=E9=96=8B=E7=AB=8B=20PR=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 描述的八個段落與順序寫在正本裡,由 pr-create 擋;正本負責的是腳本擋不住的事: 測試結果要貼實際輸出而不是改寫成一句話、被擋下來時補真的內容而不是為了通過而拼湊、 以及跨兩個功能時用 --files 分兩次跑。 先開 PR 再停錶的理由也寫進去了:工時要記在真的有做事的那段時間上。 --- prompts/sdlc-feat.md | 73 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/prompts/sdlc-feat.md b/prompts/sdlc-feat.md index d01d4fe..350fa42 100644 --- a/prompts/sdlc-feat.md +++ b/prompts/sdlc-feat.md @@ -10,6 +10,8 @@ description: 僅由 /sdlc-feat 指令叫用。領取一顆工作包、起錶、 第二段**逐項實作**:一項一項把待辦做完並即時勾選,讓議題頁的進度條隨時反映真實狀態。 +第三段**提交與開立 PR**:把變更整理成讀得懂的歷史,開出 PR,停錶。 + 這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。 ## 輸入 @@ -183,10 +185,81 @@ reviewer 得從一堆「已完成第 N 項」裡找真正的討論。 - 語言與註解格式用的是哪一份對照 - **哪些資料範例是推理來的**(MCP 取不到的那些),讓 reviewer 知道哪幾個格式還沒人對過 +## 第三段:提交與開立 PR + +### 12. 分批提交 + +全部待辦都勾完之後才進這一段。變更依類型分批: + +``` +node scripts/commit-split.js --path <目標專案路徑> --type feat \ + --subject '<繁中描述>' [--scope <功能名>] --dry-run +``` + +`--type` 是**這次程式碼變更**的類型(`feat`/`fix`/`refactor`…);測試、文件與設定檔 +由腳本自己認出來,各自成批,不必也不能指定。 + +`--scope` 只在某一批有多個檔案時才需要:單檔那批的 scope 就是檔名。試跑會印出將建立的 +每一顆 commit 與它各自的檔案,確認無誤後拿掉旗標再跑一次。 + +**描述用繁體中文。** 日後回顧時看得懂的是中文;夾雜英文的專有名詞(函式名、旗標名) +保留原文即可。 + +一次變更橫跨兩個不相干的功能時,用 `--files` **分兩次跑**: + +``` +node scripts/commit-split.js ... --files scripts/claim.js,test/claim.test.js +``` + +一顆 commit 的描述只說得清楚一件事,硬湊在一起就失去了分批的意義。 + +### 13. 寫 PR 描述 + +固定八個段落,順序不能換——reviewer 每次都在同一個位置找到要找的資訊: + +1. **摘要** — 這個 PR 做完之後,什麼事變得可能。 +2. **需求議題** — `#<編號>`。 +3. **工作包議題** — `#<編號>`。 +4. **變更內容** — 改了什麼。commit 一覽加上新增/修改的檔案。 +5. **設計重點** — 為什麼這樣做。取捨與理由,不是實作步驟的複述。 +6. **解決的問題** — 這次修掉了什麼。有具體觸發條件的就寫出來。 +7. **影響的功能** — 誰會被影響、既有行為有沒有改變。 +8. **測試結果** — 見下。 + +**「測試結果」放實際跑過的輸出**,原樣貼上,不要改寫成「已測試通過」——那句話看不出 +跑過什麼,reviewer 沒辦法據以判斷。沒有自動化測試時,寫出 reviewer 自己能重現的手動 +驗證步驟(跑什麼指令、看到什麼算對)。 + +`pr-create` 會擋下缺段落、順序不對、以及測試結果只有空話的描述。被擋下來時**補真的內容**, +不要為了通過而拼湊。 + +### 14. 開 PR 並停錶 + +``` +node scripts/pr-create.js --repo --head <分支名> \ + --body-file <描述檔> --index <工作包編號> --dry-run +``` + +標題由腳本設為分支名,不必也不能另外指定。`--index` 是工作包議題編號,PR 開出去之後 +它會停掉那顆議題上的碼錶。 + +順序是**先開 PR 再停錶**,而且 PR 沒開成就不停錶——工時要記在真的有做事的那段時間上。 +錶本來就沒在跑不算失敗(`碼錶已停` 會是 `false` 並附一句說明),PR 仍然開出去了。 + +### 15. 回報 + +- PR 的網址與編號、標題(等同分支名) +- 建立了哪幾顆 commit +- 碼錶是否已停 +- 議題上還有沒有沒勾完的待辦(理論上應該沒有;有的話要說出來) + ## 邊界 - 第一段**不改任何一行程式碼**、不勾待辦、不提交、不開 PR——那些是後面幾段的事。 - 第二段只實作與勾選。**不提交、不開 PR、不停錶**——那是第三段的事。 +- 第三段不改任何一行程式碼。到這裡實作已經結束,要改就回第二段改完再來。 +- 不把「已測試通過」這種空話寫進 PR 描述,也不為了通過檢查而拼湊內容。 +- 不代替使用者決定 commit 的類型與描述;`--type` 與 `--subject` 都要是這次真的做了什麼。 - 不把實作規範或註解格式寫進目標專案的任何檔案。 - 不改與待辦無關的程式碼;順手想修的東西記下來說出來,不要摸進這次的變更裡。 - 不為了勾選在議題上留留言。 -- 2.53.0 From 513dad7f6a6f4ef15f3f07a89525c8c4aac91bae Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 08:23:23 +0000 Subject: [PATCH 06/15] =?UTF-8?q?test(sdlc-feat-assets):=20=E5=8A=A0?= =?UTF-8?q?=E5=85=A5=E7=AC=AC=E4=B8=89=E6=AE=B5=E3=80=8C=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E8=88=87=E9=96=8B=E7=AB=8B=20PR=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 描述的八個段落與順序寫在正本裡,由 pr-create 擋;正本負責的是腳本擋不住的事: 測試結果要貼實際輸出而不是改寫成一句話、被擋下來時補真的內容而不是為了通過而拼湊、 以及跨兩個功能時用 --files 分兩次跑。 先開 PR 再停錶的理由也寫進去了:工時要記在真的有做事的那段時間上。 --- test/sdlc-feat-assets.test.js | 77 ++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/test/sdlc-feat-assets.test.js b/test/sdlc-feat-assets.test.js index 5ee051a..13e381d 100644 --- a/test/sdlc-feat-assets.test.js +++ b/test/sdlc-feat-assets.test.js @@ -11,7 +11,8 @@ import { assertNeutralPrompt, readPrompt } from './helpers/prompt-doc.js'; const prompt = readPrompt('sdlc-feat'); /** 各段的內容分開切,避免把別段的字樣誤認成這一段的規則 */ const phase1 = prompt.slice(prompt.indexOf('## 第一段'), prompt.indexOf('## 第二段')); -const phase2 = prompt.slice(prompt.indexOf('## 第二段'), prompt.indexOf('## 邊界')); +const phase2 = prompt.slice(prompt.indexOf('## 第二段'), prompt.indexOf('## 第三段')); +const phase3 = prompt.slice(prompt.indexOf('## 第三段'), prompt.indexOf('## 邊界')); test('正本平台中立,description 前綴正確', () => { assertNeutralPrompt(prompt, 'sdlc-feat'); @@ -180,3 +181,77 @@ test('邊界把第二段不做的事也列出來', () => { assert.match(boundary, /不提交、不開 PR、不停錶/); assert.match(boundary, /不改與待辦無關的程式碼/); }); + +// ── 第三段:提交與開立 PR ───────────────────────────────────────── + +test('第三段指名兩支腳本,順序為先提交再開 PR', () => { + const order = ['commit-split.js', 'pr-create.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, /全部待辦都勾完之後才進這一段/); +}); + +test('--type 是程式碼那一批的類型,其餘由腳本自己認', () => { + assert.match(phase3, /測試、文件與設定檔\s*\n?由腳本自己認出來|由腳本自己認出來/); + assert.match(phase3, /不必也不能指定/); +}); + +test('--scope 什麼時候要給寫清楚了', () => { + assert.match(phase3, /只在某一批有多個檔案時才需要/); + assert.match(phase3, /單檔那批的 scope 就是檔名/); +}); + +test('commit 描述要用繁體中文,並交代夾雜英文的處理', () => { + assert.match(phase3, /描述用繁體中文/); + assert.match(phase3, /保留原文/); +}); + +test('跨兩個功能時要分兩次跑,且指名用哪個旗標做得到', () => { + assert.match(phase3, /分兩次跑/); + assert.match(phase3, /--files/, '光說「分兩次跑」而不說怎麼分,等於沒說'); + assert.match(phase3, /失去了分批的意義/); +}); + +test('PR 描述的八個段落都列出來,且標明順序不能換', () => { + for (const section of [ + '摘要', '需求議題', '工作包議題', '變更內容', + '設計重點', '解決的問題', '影響的功能', '測試結果', + ]) { + assert.match(phase3, new RegExp(`\\*\\*${section}\\*\\*`), `缺少段落說明:${section}`); + } + assert.match(phase3, /順序不能換/); +}); + +test('測試結果要放實際輸出,並交代沒有自動化測試時怎麼辦', () => { + assert.match(phase3, /放實際跑過的輸出/); + assert.match(phase3, /已測試通過/, '要指名這句被禁止的寫法'); + assert.match(phase3, /手動\s*\n?驗證步驟|手動驗證步驟/); + assert.match(phase3, /補真的內容/); +}); + +test('標題由腳本設為分支名,不另外指定', () => { + assert.match(phase3, /標題由腳本設為分支名/); + assert.match(phase3, /不必也不能另外指定/); +}); + +test('先開 PR 再停錶,且 PR 沒開成就不停錶', () => { + assert.match(phase3, /先開 PR 再停錶/); + assert.match(phase3, /沒開成就不停錶/); + assert.match(phase3, /工時要記在真的有做事的那段時間上/); +}); + +test('兩支腳本都要求先試跑', () => { + const dryRuns = phase3.match(/--dry-run/g) ?? []; + assert.ok(dryRuns.length >= 2, `兩支寫入型腳本各要先試跑,只找到 ${dryRuns.length} 處`); +}); + +test('邊界把第三段不做的事也列出來', () => { + const boundary = prompt.slice(prompt.indexOf('## 邊界')); + assert.match(boundary, /第三段不改任何一行程式碼/); + assert.match(boundary, /不把「已測試通過」這種空話/); + assert.match(boundary, /不代替使用者決定 commit 的類型與描述/); +}); -- 2.53.0 From f99adab2455ead61847217a02e6b47559aa8c18d Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 08:23:23 +0000 Subject: [PATCH 07/15] =?UTF-8?q?fix(commit-split):=20=E6=94=B9=E5=90=8D?= =?UTF-8?q?=E6=99=82=E5=88=A5=E6=BC=8F=E6=8E=89=E8=88=8A=E6=AA=94=E7=9A=84?= =?UTF-8?q?=E5=88=AA=E9=99=A4=EF=BC=8C=E5=A4=B1=E6=95=97=E6=99=82=E8=AA=AA?= =?UTF-8?q?=E5=87=BA=E5=81=9A=E5=88=B0=E5=93=AA=E8=A3=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git diff --name-only 預設偵測改名,只印出目的地那一個路徑。來源的刪除因此被漏掉—— 留在 index 裡沒被提交,而腳本還回報成功,要等下一次跑才會發現工作區不乾淨。加 --no-renames。 某一批提交失敗時,錯誤現在會列出前面已經建立的那幾顆 commit。不回捲它們:那會動到使用者的 歷史,而那幾顆本身是好的;但一定要說出做到哪裡,否則重跑前得自己去翻 git log。 類型對照表補上目標專案常見的測試擺法:tests/、spec/、__tests__/,以及放在被測檔案旁邊的 user.test.js。先前只認 test/,目標專案的測試會被併進 feat 那一批。 --- scripts/commit-split.js | 53 ++++++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/scripts/commit-split.js b/scripts/commit-split.js index 7d1ad15..33e2884 100644 --- a/scripts/commit-split.js +++ b/scripts/commit-split.js @@ -20,9 +20,8 @@ * [--scope <功能名>] [--body '<為什麼>'] [--files a.js,b.js] * [--path <目標專案>] [--dry-run] */ -import { existsSync } from 'node:fs'; -import { basename, join } from 'node:path'; -import { ScriptError, main, parseFlags, runGit } from './lib.js'; +import { basename } from 'node:path'; +import { ScriptError, main, openGitRepo, parseFlags } from './lib.js'; /** commit 訊息的類型。與既有 git 歷史一致,不另立新詞。 */ const TYPES = ['feat', 'fix', 'refactor', 'test', 'docs', 'chore', 'perf', 'style']; @@ -34,7 +33,13 @@ const TYPES = ['feat', 'fix', 'refactor', 'test', 'docs', 'chore', 'perf', 'styl * 都是產品本身,是新增還是修正得由做的人說,所以不在這張表裡——它們吃 `--type`。 */ const BY_PATH = [ - { type: 'test', match: (path) => path.startsWith('test/') }, + { + // 目標專案的測試未必放在 test/:tests/、spec/、__tests__/ 都常見, + // 也常見把 user.test.js 放在被測檔案旁邊 + type: 'test', + match: (path) => + /(^|\/)(tests?|spec|__tests__)\//.test(path) || /\.(test|spec)\.[^./]+$/.test(path), + }, { type: 'docs', match: (path) => /^[^/]+\.md$/.test(path) || path.startsWith('docs/') }, { type: 'chore', @@ -57,11 +62,7 @@ main(async () => { const type = parseType(flags.type); const subject = parseSubject(flags.subject); - if (!existsSync(join(path, '.git'))) { - throw new ScriptError('NOT_A_GIT_REPO', `${path} 不是 git repo;請用 --path 指向目標專案的根目錄`); - } - - const git = (...args) => runGit(args, { cwd: path }); + const git = openGitRepo(path); const { changed, untracked } = changedFiles(git); if (changed.length === 0) { throw new ScriptError('NOTHING_TO_COMMIT', `${path} 的工作區是乾淨的,沒有東西可以提交`); @@ -73,13 +74,28 @@ main(async () => { return { dryRun: true, path, commits }; } + const done = []; for (const { message, files } of commits) { - // 只有未追蹤的檔案需要先 add:commit 帶 pathspec 不會把新檔案收進來, - // 但已追蹤的修改與刪除它自己處理得了。對已經被 git rm 掉的檔案再 add 一次只會報 - // 「找不到這個路徑」——那個檔案本來就已經不在工作區也不在 index 裡了。 - const toAdd = files.filter((file) => untracked.has(file)); - if (toAdd.length > 0) git('add', '--', ...toAdd); - git('commit', '-m', message, '--', ...files); + try { + // 只有未追蹤的檔案需要先 add:commit 帶 pathspec 不會把新檔案收進來, + // 但已追蹤的修改與刪除它自己處理得了。對已經被 git rm 掉的檔案再 add 一次只會報 + // 「找不到這個路徑」——那個檔案本來就已經不在工作區也不在 index 裡了。 + const toAdd = files.filter((file) => untracked.has(file)); + if (toAdd.length > 0) git('add', '--', ...toAdd); + git('commit', '-m', message, '--', ...files); + done.push(message); + } catch (cause) { + // 不回捲已經建立的 commit:那會動到使用者的歷史,而這幾顆本身是好的。 + // 但一定要說出做到哪裡,否則重跑前得自己去翻 git log。 + throw new ScriptError( + 'COMMIT_FAILED', + `這一批提交失敗:${message.split('\n')[0]}(${cause.message})。` + + (done.length > 0 + ? `在此之前已經建立:${done.map((m) => m.split('\n')[0]).join('、')};` + + '修掉原因之後重跑即可,已建立的那幾顆不會重複。' + : '還沒有任何 commit 被建立。'), + ); + } } return { path, commits: commits.map(({ message, files }) => ({ message, files })) }; }); @@ -96,8 +112,11 @@ main(async () => { * @returns {{changed: string[], untracked: Set}} */ function changedFiles(git) { - // 已追蹤的改動:staged 與未 staged 都算,刪除與改名(列為一刪一增)也在內 - const tracked = git('diff', '--name-only', 'HEAD').split('\n').filter((file) => file !== ''); + // --no-renames 是必要的:git 預設偵測改名,只印出目的地那一個路徑, + // 來源的刪除就會被漏掉——留在 index 裡沒被提交,而腳本還回報成功 + const tracked = git('diff', '--name-only', '--no-renames', 'HEAD') + .split('\n') + .filter((file) => file !== ''); const untracked = git('ls-files', '--others', '--exclude-standard') .split('\n') .filter((file) => file !== ''); -- 2.53.0 From 8a6145ea14b5d197c2c4416ec9c900d1de1bb6c9 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 08:23:24 +0000 Subject: [PATCH 08/15] =?UTF-8?q?test(commit-split):=20=E6=94=B9=E5=90=8D?= =?UTF-8?q?=E6=99=82=E5=88=A5=E6=BC=8F=E6=8E=89=E8=88=8A=E6=AA=94=E7=9A=84?= =?UTF-8?q?=E5=88=AA=E9=99=A4=EF=BC=8C=E5=A4=B1=E6=95=97=E6=99=82=E8=AA=AA?= =?UTF-8?q?=E5=87=BA=E5=81=9A=E5=88=B0=E5=93=AA=E8=A3=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git diff --name-only 預設偵測改名,只印出目的地那一個路徑。來源的刪除因此被漏掉—— 留在 index 裡沒被提交,而腳本還回報成功,要等下一次跑才會發現工作區不乾淨。加 --no-renames。 某一批提交失敗時,錯誤現在會列出前面已經建立的那幾顆 commit。不回捲它們:那會動到使用者的 歷史,而那幾顆本身是好的;但一定要說出做到哪裡,否則重跑前得自己去翻 git log。 類型對照表補上目標專案常見的測試擺法:tests/、spec/、__tests__/,以及放在被測檔案旁邊的 user.test.js。先前只認 test/,目標專案的測試會被併進 feat 那一批。 --- test/commit-split.test.js | 40 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/commit-split.test.js b/test/commit-split.test.js index 31de5be..b46b3a5 100644 --- a/test/commit-split.test.js +++ b/test/commit-split.test.js @@ -42,6 +42,11 @@ const subjects = (repo) => const CLASSIFY = [ { path: 'test/claim.test.js', type: 'test', why: '測試檔' }, { path: 'test/helpers/stub-gitea.js', type: 'test', why: '測試用的 helper 也算測試' }, + { path: 'tests/user_test.py', type: 'test', why: '目標專案未必叫 test/' }, + { path: 'spec/user_spec.rb', type: 'test', why: '同上' }, + { path: '__tests__/user.js', type: 'test', why: '同上' }, + { path: 'src/user.test.js', type: 'test', why: '測試與程式碼放在一起也很常見' }, + { path: 'src/User.spec.ts', type: 'test', why: '同上' }, { path: 'README.md', type: 'docs', why: '根目錄的說明文件' }, { path: 'AGENTS.md', type: 'docs', why: '同上' }, { path: 'package.json', type: 'chore', why: '專案設定' }, @@ -306,6 +311,41 @@ test('被刪掉的檔案也照樣分類、照樣進 commit', async (t) => { assert.equal(repo.git('status', '--porcelain'), ''); }); +test('改名時舊檔的刪除也要進 commit,不能只提交新檔', async (t) => { + // git diff --name-only 預設偵測改名,只印目的地那一個路徑。漏掉來源等於把刪除留在 + // index 裡,而腳本還回報成功——下一次跑才會發現工作區不乾淨。 + const repo = withRepo(t); + write(repo, 'scripts/old.js'); + repo.git('add', '-A'); + repo.git('commit', '-qm', '先有這個檔案'); + repo.git('mv', 'scripts/old.js', 'scripts/new.js'); + + const { code, json } = await run(repo, ['--type', 'refactor', '--scope', '改名', '--subject', '換個名字']); + + assert.equal(code, 0, JSON.stringify(json)); + assert.deepEqual(json.data.commits[0].files, ['scripts/new.js', 'scripts/old.js']); + assert.equal(repo.git('status', '--porcelain'), '', '改名的兩邊都要進同一顆 commit'); +}); + +// ── 中途失敗 ─────────────────────────────────────────────────────── + +test('某一批提交失敗時,錯誤要說出前面已經建立了哪幾顆 commit', async (t) => { + // 沒說的話,使用者不知道做到哪裡,重跑前得自己去翻 git log + const repo = withRepo(t); + write(repo, 'scripts/one.js', 'test/one.test.js'); + // 用 pre-commit hook 擋掉測試那一批 + const hook = join(repo.dir, '.git', 'hooks', 'pre-commit'); + mkdirSync(dirname(hook), { recursive: true }); + writeFileSync(hook, '#!/bin/sh\ngit diff --cached --name-only | grep -q "^test/" && exit 1\nexit 0\n', { mode: 0o755 }); + + const { code, json } = await run(repo, ['--type', 'feat', '--subject', '做一件事']); + + assert.equal(code, 1); + assert.equal(json.error.code, 'COMMIT_FAILED'); + assert.match(json.error.message, /feat\(one\): 做一件事/, '要指名已經建立的那一顆'); + assert.match(json.error.message, /test\(one\)/, '也要指名是哪一批失敗的'); +}); + // ── --dry-run ───────────────────────────────────────────────────── test('--dry-run 印出將建立的 commit 與各自的檔案,但不提交', async (t) => { -- 2.53.0 From 30297cc49a8cf8b25993d735fda8485926d9706f Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 08:23:25 +0000 Subject: [PATCH 09/15] =?UTF-8?q?fix(pr-create):=20=E9=8C=B6=E5=81=9C?= =?UTF-8?q?=E5=9C=A8=E8=AD=B0=E9=A1=8C=E7=9A=84=20repo=EF=BC=8C=E4=B8=A6?= =?UTF-8?q?=E8=AE=93=E9=87=8D=E8=B7=91=E4=B8=8D=E6=9C=83=E9=96=8B=E5=87=BA?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E9=A1=86=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claim 在工作包議題上起錶,而 PR 開在目標專案上——議題在需求的 repo,程式碼在 repos 列的 那幾個,兩者常常不是同一個。先前用同一個 --repo 同時指 PR 與停錶,停到的會是別人的議題 (或 404 而在 PR 已建立之後才拋錯),而自己的錶還在跑。新增 --issue-repo,預設與 --repo 相同。 --index 與 --base 改為必填:停錶是這一步的一部分,忘了給會讓工時算不準;而目標專案的開發 分支可能叫 master、main 或 develop,猜錯會開到不存在的 base。 重跑先查同一個 head 有沒有開著的 PR,有就回傳它並把 created 設為 false,然後照樣停錶—— 那一步可能正是上次中斷的地方。先前重跑會撞上 Gitea 的 422,而那個錯誤看不出 PR 其實已經開好。 停錶的 500 改為只在訊息確實提到 stopwatch 時才視為「本來就沒在跑」,免得把真的伺服器錯誤 吞掉;未經證實的 409 那一支拿掉。測試結果的空話檢查改成整段每一行都是空話才擋,段落也改用 行首標題切,描述裡引用到「## 測試結果」這幾個字不會再讓檢查看錯地方。 --- scripts/pr-create.js | 113 +++++++++++++++++++++++++++++++------------ 1 file changed, 83 insertions(+), 30 deletions(-) diff --git a/scripts/pr-create.js b/scripts/pr-create.js index 25de2f0..c256f1b 100644 --- a/scripts/pr-create.js +++ b/scripts/pr-create.js @@ -13,9 +13,17 @@ * 停錶排在 PR 開出去之後,而且只在 PR 真的建立了才停:工時要記在真的有做事的那段 * 時間上。錶本來就沒在跑不算失敗——PR 已經開出去了,不該把整件事報成失敗。 * + * **錶停在議題所在的 repo,不是 PR 所在的 repo。** 工作包議題與目標專案常常不是同一個 + * repo(議題在需求的 repo,程式碼在 `repos` 列的那些),拿 PR 的 repo 去停錶,停到的是 + * 別人的議題,而自己的錶還在跑。預設兩者相同,不同時用 `--issue-repo` 指出來。 + * + * 重跑不會開出第二顆 PR:先查同一個 head 有沒有開著的 PR,有就回傳它並把 `created` + * 設為 `false`,然後照樣停錶——那一步可能正是上次中斷的地方。 + * * 用法: - * node scripts/pr-create.js --repo owner/name --head <分支> --body-file <描述檔> - * [--base master] [--index 13] [--host <網址>] [--dry-run] + * node scripts/pr-create.js --repo owner/name --head <分支> --base <分支> + * --body-file <描述檔> --index 13 + * [--issue-repo owner/name] [--host <網址>] [--dry-run] */ import { existsSync, readFileSync } from 'node:fs'; import { @@ -59,20 +67,26 @@ const EMPTY_TALK = new Set([ '全部通過', '全數通過', '皆通過', + '無異常', + '沒有問題', + '一切正常', + '正常', 'ok', 'OK', ]); main(async () => { const flags = parseFlags(process.argv.slice(2), { - required: ['repo', 'head', 'body-file'], - optional: ['base', 'index', 'host'], + required: ['repo', 'head', 'base', 'body-file', 'index'], + optional: ['issue-repo', 'host'], booleans: ['dry-run'], }); const repo = parseRepo(flags.repo); + // 議題預設與 PR 同一個 repo;跨 repo 的工作包要用 --issue-repo 指出來 + const issueRepo = parseRepo(flags['issue-repo'] ?? flags.repo); const head = flags.head; - const base = flags.base ?? 'master'; - const index = flags.index === undefined ? null : parseIndex(flags.index); + const base = flags.base; + const index = parseIndex(flags.index); const body = readBody(flags['body-file']); // 描述先驗完再談寫入:不合格的描述不該等到實跑才發現 @@ -80,42 +94,72 @@ main(async () => { checkTestResult(body); const pullsPath = `/repos/${repo}/pulls`; - const stopPath = index === null ? null : `/repos/${repo}/issues/${index}/stopwatch/stop`; + const stopPath = `/repos/${issueRepo}/issues/${index}/stopwatch/stop`; const payload = { title: head, head, base, body }; + // 試跑也把登入解出來:沒跑過 tea login 的話,這一步就會說出來,不必等到實跑 + const login = resolveLogin({ host: flags.host }); + if (flags['dry-run']) { - const requests = [{ method: 'POST', path: pullsPath, body: payload }]; - if (stopPath) requests.push({ method: 'POST', path: stopPath, body: {} }); - return { dryRun: true, repo, head, base, title: head, requests }; + return { + dryRun: true, + repo, + issueRepo, + index, + head, + base, + title: head, + requests: [ + { method: 'POST', path: pullsPath, body: payload }, + { method: 'POST', path: stopPath, body: {} }, + ], + }; } - const login = resolveLogin({ host: flags.host }); await preflight(login, repo); - const pull = expectOk( + // 冪等:同一個 head 已經有開著的 PR 就用它,重跑不會開出第二顆 + const existing = await findOpenPull(login, repo, head); + const pull = existing ?? expectOk( await giteaRequest(login, 'POST', pullsPath, { body: payload }), `POST ${pullsPath}`, ); - // 錶只在 PR 真的開出去之後才停 - const stopped = stopPath === null ? false : await stopStopwatch(login, stopPath); + // 錶只在 PR 確實存在之後才停。既有的 PR 也要停——那一步可能正是上次中斷的地方。 + const stopped = await stopStopwatch(login, stopPath); return { repo, + issueRepo, index, + created: existing === null, title: pull.title, url: pull.html_url, number: pull.number, head, base, 碼錶已停: stopped, - ...(stopped || stopPath === null - ? {} - : { note: '碼錶本來就沒在這顆議題上運轉,PR 已經開出去了,這一步略過。' }), + ...(stopped ? {} : { note: '碼錶本來就沒在這顆議題上運轉,PR 已經在了,這一步略過。' }), }; }); +/** + * 找同一個 head 上開著的 PR。 + * 重跑時 Gitea 會對重複的 PR 回 422,而那個錯誤看不出「其實已經開好了」—— + * 先查一次,重跑就是安靜地接上。 + */ +async function findOpenPull(login, repo, head) { + const path = `/repos/${repo}/pulls`; + const pulls = expectOk( + await giteaRequest(login, 'GET', path, { query: { state: 'open' } }), + `GET ${path}`, + ) ?? []; + + return pulls.find((pull) => pull.head?.ref === head) ?? null; +} + + function readBody(path) { if (!existsSync(path)) { throw new ScriptError('BODY_FILE_NOT_FOUND', `找不到描述檔 ${path}`); @@ -147,21 +191,25 @@ function checkSections(body) { /** * 「測試結果」不能是空話。 - * 判斷很窄——只看「整段只有一行,而那一行是已知的偷懶寫法」。窄是刻意的: - * 這一關要擋的是明顯沒跑過就交差,不是去評價別人的測試寫得夠不夠好。 + * 判斷很窄——整段的每一行都是已知的偷懶寫法才算。窄是刻意的: + * 這一關要擋的是明顯沒跑過就交差,不是去評價別人的測試寫得夠不夠好, + * 所以只要混進了一行真的輸出就放行。 */ function checkTestResult(body) { - const section = body.slice(body.indexOf('## 測試結果')); - const content = section - .split('\n') - .slice(1) - .join('\n') - .trim(); + const lines = body.split('\n'); + // 找行首的那個標題,而不是 indexOf:描述裡引用到「## 測試結果」這幾個字是常有的事 + const start = lines.findIndex((line) => /^##\s+測試結果\s*$/.test(line)); + const rest = lines.slice(start + 1); + const end = rest.findIndex((line) => /^##\s+/.test(line)); + const content = (end === -1 ? rest : rest.slice(0, end)).join('\n').trim(); - const lines = content.split('\n').filter((line) => line.trim() !== ''); - const onlyLine = lines.length === 1 ? lines[0].trim().replace(/[。..]$/, '') : null; + const written = content.split('\n').filter((line) => line.trim() !== ''); + // 每一行都是空話才算空話:混了實際輸出就放行,這一關不評價測試寫得好不好 + const allEmptyTalk = + written.length > 0 && + written.every((line) => EMPTY_TALK.has(line.trim().replace(/[。..]$/, ''))); - if (content === '' || (onlyLine !== null && EMPTY_TALK.has(onlyLine))) { + if (content === '' || allEmptyTalk) { throw new ScriptError( 'EMPTY_TEST_RESULT', '「測試結果」要放實際跑過的輸出;沒有自動化測試時,寫出 reviewer 自己能重現的' + @@ -177,7 +225,12 @@ function checkTestResult(body) { async function stopStopwatch(login, path) { const response = await giteaRequest(login, 'POST', path, { body: {} }); if (response.status >= 200 && response.status < 300) return true; - if (response.status === 500 || response.status === 409) return false; - return expectOk(response, `POST ${path}`) !== undefined; + // Gitea 對「這顆議題上沒有碼錶在跑」回的是 500。那不是失敗—— + // 只有這一種 500 能這樣看待,訊息對不上就照常拋,免得把真的伺服器錯誤吞掉。 + if (response.status === 500 && /stopwatch/i.test(response.body?.message ?? '')) { + return false; + } + expectOk(response, `POST ${path}`); + return false; } -- 2.53.0 From fbc80f286fbaeb83f7254aa63f3080a385059f31 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 08:23:25 +0000 Subject: [PATCH 10/15] =?UTF-8?q?test(pr-create):=20=E9=8C=B6=E5=81=9C?= =?UTF-8?q?=E5=9C=A8=E8=AD=B0=E9=A1=8C=E7=9A=84=20repo=EF=BC=8C=E4=B8=A6?= =?UTF-8?q?=E8=AE=93=E9=87=8D=E8=B7=91=E4=B8=8D=E6=9C=83=E9=96=8B=E5=87=BA?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E9=A1=86=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claim 在工作包議題上起錶,而 PR 開在目標專案上——議題在需求的 repo,程式碼在 repos 列的 那幾個,兩者常常不是同一個。先前用同一個 --repo 同時指 PR 與停錶,停到的會是別人的議題 (或 404 而在 PR 已建立之後才拋錯),而自己的錶還在跑。新增 --issue-repo,預設與 --repo 相同。 --index 與 --base 改為必填:停錶是這一步的一部分,忘了給會讓工時算不準;而目標專案的開發 分支可能叫 master、main 或 develop,猜錯會開到不存在的 base。 重跑先查同一個 head 有沒有開著的 PR,有就回傳它並把 created 設為 false,然後照樣停錶—— 那一步可能正是上次中斷的地方。先前重跑會撞上 Gitea 的 422,而那個錯誤看不出 PR 其實已經開好。 停錶的 500 改為只在訊息確實提到 stopwatch 時才視為「本來就沒在跑」,免得把真的伺服器錯誤 吞掉;未經證實的 409 那一支拿掉。測試結果的空話檢查改成整段每一行都是空話才擋,段落也改用 行首標題切,描述裡引用到「## 測試結果」這幾個字不會再讓檢查看錯地方。 --- test/pr-create.test.js | 190 ++++++++++++++++++++++++++++++++++------- 1 file changed, 157 insertions(+), 33 deletions(-) diff --git a/test/pr-create.test.js b/test/pr-create.test.js index f8421f1..a5dce61 100644 --- a/test/pr-create.test.js +++ b/test/pr-create.test.js @@ -14,7 +14,10 @@ import { join } from 'node:path'; import { runScript, tmpRoot } from './helpers/run-script.js'; import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js'; -const REPO = 'plugins/tea-sdlc'; +/** PR 開在目標專案上 */ +const REPO = 'myorg/myapp'; +/** 工作包議題在另一個 repo 上——這是常態,不是特例 */ +const ISSUE_REPO = 'plugins/tea-sdlc'; const HEAD = 'feat/commit-split-and-pr/main'; const INDEX = 13; @@ -66,19 +69,26 @@ function bodyFile(name, content) { function routes(overrides = {}) { return healthyRoutes(REPO, { + [`GET /api/v1/repos/${REPO}/pulls`]: { status: 200, body: [] }, [`POST /api/v1/repos/${REPO}/pulls`]: (req) => ({ status: 201, body: { number: 99, title: req.body.title, html_url: `https://gitea.jsc.idv.tw/${REPO}/pulls/99` }, }), - [`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`]: { status: 201, body: {} }, + [`POST /api/v1/repos/${ISSUE_REPO}/issues/${INDEX}/stopwatch/stop`]: { status: 201, body: {} }, ...overrides, }); } const withStub = (t, overrides = {}) => withStubGitea(t, routes(overrides)); +const BASE_ARGS = ['--repo', REPO, '--head', HEAD, '--base', 'master']; + const run = (args, stub) => - runScript('pr-create.js', ['--repo', REPO, '--head', HEAD, ...args], { env: envFor(stub) }); + runScript('pr-create.js', [...BASE_ARGS, ...args], { env: envFor(stub) }); + +/** 完整的一次呼叫:議題在另一個 repo 上 */ +const runFull = (file, stub, extra = []) => + run(['--body-file', file, '--issue-repo', ISSUE_REPO, '--index', String(INDEX), ...extra], stub); const posts = (stub) => stub.requests.filter((r) => r.method === 'POST').map((r) => r.path); @@ -89,10 +99,10 @@ test('PR 標題等同分支名', async (t) => { const stub = await withStub(t); const file = bodyFile('full', BODY); - const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub); + const { code, json } = await runFull(file, stub); assert.equal(code, 0, JSON.stringify(json)); - const pull = stub.requests.find((r) => r.path.endsWith('/pulls')); + const pull = stub.requests.find((r) => r.method === 'POST' && r.path.endsWith('/pulls')); assert.equal(pull.body.title, HEAD); assert.equal(json.data.title, HEAD); }); @@ -101,24 +111,33 @@ test('描述原樣送出,一個字都不改寫', async (t) => { const stub = await withStub(t); const file = bodyFile('verbatim', BODY); - await run(['--body-file', file, '--index', String(INDEX)], stub); + await runFull(file, stub); - const pull = stub.requests.find((r) => r.path.endsWith('/pulls')); + const pull = stub.requests.find((r) => r.method === 'POST' && r.path.endsWith('/pulls')); assert.equal(pull.body.body, BODY); }); -test('base 預設為 master,也可以指定', async (t) => { +test('--base 照給的值送出,不預設猜一個', async (t) => { + // 目標專案的開發分支可能叫 master、main 或 develop,猜錯會開到不存在的 base const stub = await withStub(t); const file = bodyFile('base', BODY); - await run(['--body-file', file, '--index', String(INDEX)], stub); - const first = stub.requests.find((r) => r.path.endsWith('/pulls')); - assert.equal(first.body.base, 'master'); + await runFull(file, stub); - const stub2 = await withStub(t); - const file2 = bodyFile('base2', BODY); - await run(['--body-file', file2, '--index', String(INDEX), '--base', 'develop'], stub2); - assert.equal(stub2.requests.find((r) => r.path.endsWith('/pulls')).body.base, 'develop'); + assert.equal(stub.requests.find((r) => r.method === 'POST').body.base, 'master'); +}); + +test('沒給 --base 時擋下,並說明為什麼不替你猜', async (t) => { + const stub = await withStub(t); + const file = bodyFile('nobase', BODY); + + const { json } = await runScript('pr-create.js', [ + '--repo', REPO, '--head', HEAD, '--body-file', file, + '--issue-repo', ISSUE_REPO, '--index', String(INDEX), + ], { env: envFor(stub) }); + + assert.equal(json.error.code, 'MISSING_FLAG'); + assert.match(json.error.message, /--base/); }); // ── 七段:少一段就擋 ─────────────────────────────────────────────── @@ -136,7 +155,7 @@ for (const missing of SECTIONS) { .join('## '); const file = bodyFile(`missing-${missing}`, body); - const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub); + const { code, json } = await runFull(file, stub); assert.equal(code, 1); assert.equal(json.error.code, 'MISSING_SECTION'); @@ -153,11 +172,34 @@ test('段落順序不對時也擋下:reviewer 每次要在同一個位置找 ); const file = bodyFile('order', swapped); - const { json } = await run(['--body-file', file, '--index', String(INDEX)], stub); + const { json } = await runFull(file, stub); assert.equal(json.error.code, 'SECTION_ORDER'); }); +test('測試結果整段都是空話時擋下,不只看單行', async (t) => { + const stub = await withStub(t); + const body = BODY.replace(/## 測試結果[\s\S]*$/, '## 測試結果\n\n已測試通過\n無異常\n'); + const file = bodyFile('multi-talk', body); + + const { json } = await runFull(file, stub); + + assert.equal(json.error.code, 'EMPTY_TEST_RESULT'); +}); + +test('描述裡引用到「## 測試結果」這幾個字時,檢查的仍是真正那一段', async (t) => { + const stub = await withStub(t); + const body = BODY.replace( + '新增 commit-split 與 pr-create 兩支腳本。', + '新增兩支腳本,並要求 `## 測試結果` 這一段放實際輸出。', + ); + const file = bodyFile('quoted-heading', body); + + const { code, json } = await runFull(file, stub); + + assert.equal(code, 0, JSON.stringify(json)); +}); + // ── 測試結果不能是空話 ───────────────────────────────────────────── const EMPTY_TALK = ['已測試通過', '測試通過', '全部通過', '測試皆已通過', '無']; @@ -168,7 +210,7 @@ for (const talk of EMPTY_TALK) { const body = BODY.replace(/## 測試結果[\s\S]*$/, `## 測試結果\n\n${talk}\n`); const file = bodyFile(`talk-${talk}`, body); - const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub); + const { code, json } = await runFull(file, stub); assert.equal(code, 1); assert.equal(json.error.code, 'EMPTY_TEST_RESULT'); @@ -181,7 +223,7 @@ test('測試結果是空的時候擋下', async (t) => { const stub = await withStub(t); const file = bodyFile('empty', BODY.replace(/## 測試結果[\s\S]*$/, '## 測試結果\n\n')); - const { json } = await run(['--body-file', file, '--index', String(INDEX)], stub); + const { json } = await runFull(file, stub); assert.equal(json.error.code, 'EMPTY_TEST_RESULT'); }); @@ -196,7 +238,7 @@ test('沒有自動化測試時,寫得出可重現的手動驗證步驟就放 ); const file = bodyFile('manual', manual); - const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub); + const { code, json } = await runFull(file, stub); assert.equal(code, 0, JSON.stringify(json)); }); @@ -207,21 +249,50 @@ test('PR 開完之後才停錶,順序不能反', async (t) => { const stub = await withStub(t); const file = bodyFile('stop', BODY); - await run(['--body-file', file, '--index', String(INDEX)], stub); + await runFull(file, stub); assert.deepEqual(posts(stub), [ `/api/v1/repos/${REPO}/pulls`, - `/api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`, + `/api/v1/repos/${ISSUE_REPO}/issues/${INDEX}/stopwatch/stop`, ]); }); +test('錶停在議題所在的 repo,不是 PR 所在的 repo', async (t) => { + // claim 在工作包議題上起錶,而 PR 開在目標專案上——兩者常常不是同一個 repo。 + // 拿 PR 的 repo 去停錶,停到的是別人的議題,而自己的錶還在跑。 + const stub = await withStub(t); + const file = bodyFile('two-repos', BODY); + + const { code, json } = await runFull(file, stub); + + assert.equal(code, 0, JSON.stringify(json)); + assert.equal(json.data.碼錶已停, true); + assert.equal( + posts(stub).some((path) => path.startsWith(`/api/v1/repos/${REPO}/issues/`)), + false, + '不該對 PR 的那個 repo 發停錶請求', + ); +}); + +test('沒給 --issue-repo 時,議題就在 PR 的同一個 repo 上', async (t) => { + const stub = await withStubGitea(t, routes({ + [`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`]: { status: 201, body: {} }, + })); + const file = bodyFile('same-repo', BODY); + + const { code } = await run(['--body-file', file, '--index', String(INDEX)], stub); + + assert.equal(code, 0); + assert.ok(posts(stub).includes(`/api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`)); +}); + test('PR 開失敗時不停錶:工時要記在真的有做事的那段時間上', async (t) => { const stub = await withStub(t, { [`POST /api/v1/repos/${REPO}/pulls`]: { status: 422, body: { message: 'pull request already exists' } }, }); const file = bodyFile('fail', BODY); - const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub); + const { code, json } = await runFull(file, stub); assert.equal(code, 1); assert.equal( @@ -235,29 +306,79 @@ test('PR 開失敗時不停錶:工時要記在真的有做事的那段時間 test('錶本來就沒在跑時不算失敗:PR 已經開出去了', async (t) => { // Gitea 對「沒有碼錶在跑」回 500;這時 PR 已經建立,不該把整件事報成失敗 const stub = await withStub(t, { - [`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`]: { + [`POST /api/v1/repos/${ISSUE_REPO}/issues/${INDEX}/stopwatch/stop`]: { status: 500, body: { message: 'cannot stop a non existent stopwatch' }, }, }); const file = bodyFile('nowatch', BODY); - const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub); + const { code, json } = await runFull(file, stub); assert.equal(code, 0, JSON.stringify(json)); assert.equal(json.data.碼錶已停, false); assert.match(json.data.note ?? '', /碼錶/); }); -test('沒給 --index 就不停錶,也不報錯', async (t) => { +test('沒給 --index 時擋下:停錶是這一步的一部分,忘了給會讓工時算不準', async (t) => { const stub = await withStub(t); const file = bodyFile('noindex', BODY); - const { code, json } = await run(['--body-file', file], stub); + const { json } = await run(['--body-file', file], stub); + + assert.equal(json.error.code, 'MISSING_FLAG'); + assert.match(json.error.message, /--index/); +}); + +// ── 冪等:重跑不會開出第二顆 PR ─────────────────────────────────── + +test('同一個 head 已經有開著的 PR 時回傳既有那一顆,不再開一顆', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/pulls`]: { + status: 200, + body: [{ number: 7, title: HEAD, html_url: 'https://example.com/7', head: { ref: HEAD } }], + }, + }); + const file = bodyFile('dup', BODY); + + const { code, json } = await runFull(file, stub); assert.equal(code, 0, JSON.stringify(json)); - assert.deepEqual(posts(stub), [`/api/v1/repos/${REPO}/pulls`]); - assert.equal(json.data.碼錶已停, false); + assert.equal(json.data.created, false); + assert.equal(json.data.number, 7); + assert.equal( + posts(stub).some((path) => path.endsWith('/pulls')), + false, + '既有的那一顆就是答案,不要再開一顆', + ); +}); + +test('已經有 PR 時照樣停錶:那一步可能是上次中斷的地方', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/pulls`]: { + status: 200, + body: [{ number: 7, title: HEAD, html_url: 'https://example.com/7', head: { ref: HEAD } }], + }, + }); + const file = bodyFile('dup-stop', BODY); + + const { json } = await runFull(file, stub); + + assert.equal(json.data.碼錶已停, true); +}); + +test('別的分支的 PR 不算數', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/pulls`]: { + status: 200, + body: [{ number: 7, title: '別的', html_url: 'https://example.com/7', head: { ref: 'feat/別的/main' } }], + }, + }); + const file = bodyFile('other-branch', BODY); + + const { json } = await runFull(file, stub); + + assert.equal(json.data.created, true); }); // ── 輸入 ─────────────────────────────────────────────────────────── @@ -265,7 +386,10 @@ test('沒給 --index 就不停錶,也不報錯', async (t) => { test('描述檔不存在時回可區分的錯誤碼', async (t) => { const stub = await withStub(t); - const { json } = await run(['--body-file', join(tmpRoot, '不存在的檔案.md')], stub); + const { json } = await run( + ['--body-file', join(tmpRoot, '不存在的檔案.md'), '--index', String(INDEX)], + stub, + ); assert.equal(json.error.code, 'BODY_FILE_NOT_FOUND'); }); @@ -276,7 +400,7 @@ test('--dry-run 印出將建立的 PR 與將停的錶,但不碰 Gitea', async const stub = await withStub(t); const file = bodyFile('dry', BODY); - const { code, json } = await run(['--body-file', file, '--index', String(INDEX), '--dry-run'], stub); + const { code, json } = await runFull(file, stub, ['--dry-run']); assert.equal(code, 0); assert.equal(json.data.dryRun, true); @@ -284,7 +408,7 @@ test('--dry-run 印出將建立的 PR 與將停的錶,但不碰 Gitea', async json.data.requests.map((r) => `${r.method} ${r.path}`), [ `POST /repos/${REPO}/pulls`, - `POST /repos/${REPO}/issues/${INDEX}/stopwatch/stop`, + `POST /repos/${ISSUE_REPO}/issues/${INDEX}/stopwatch/stop`, ], ); assert.equal(json.data.requests[0].body.title, HEAD, '試跑要看得到標題長什麼樣'); @@ -295,7 +419,7 @@ test('--dry-run 照樣驗描述:不合格的描述不該等到實跑才發現' const stub = await withStub(t); const file = bodyFile('dry-bad', BODY.replace('## 測試結果', '## 測試結論')); - const { json } = await run(['--body-file', file, '--dry-run'], stub); + const { json } = await runFull(file, stub, ['--dry-run']); assert.equal(json.error.code, 'MISSING_SECTION'); }); -- 2.53.0 From 95e60269504adc33cbd15aa819192fca3b46767a Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 08:23:26 +0000 Subject: [PATCH 11/15] =?UTF-8?q?refactor(lib):=20=E9=96=8B=E5=95=9F?= =?UTF-8?q?=E7=9B=AE=E6=A8=99=E5=B0=88=E6=A1=88=20git=20repo=20=E7=9A=84?= =?UTF-8?q?=E9=82=A3=E5=B9=BE=E8=A1=8C=E6=94=B6=E9=80=B2=20lib?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「路徑不是 repo 就報 NOT_A_GIT_REPO」加上「把 cwd 綁進 runGit」原本在 branch-prep 與 commit-split 各寫一份。錯誤碼要一致,而這件事寫第三遍就該收起來了。 --- scripts/branch-prep.js | 10 ++-------- scripts/lib.js | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/scripts/branch-prep.js b/scripts/branch-prep.js index bee193a..398c756 100644 --- a/scripts/branch-prep.js +++ b/scripts/branch-prep.js @@ -23,9 +23,7 @@ * node scripts/branch-prep.js --source <來源分支> --slug <英文-kebab> * [--type feat] [--path <目標專案>] [--dry-run] */ -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { ScriptError, main, parseFlags, runGit } from './lib.js'; +import { ScriptError, main, openGitRepo, parseFlags } from './lib.js'; /** 需求描述的長度上限。超過就換一個短的說法,不要靠截斷。 */ const SLUG_MAX = 40; @@ -43,11 +41,7 @@ main(async () => { const source = flags.source; const branch = buildBranchName(source, flags.slug, flags.type); - if (!existsSync(join(path, '.git'))) { - throw new ScriptError('NOT_A_GIT_REPO', `${path} 不是 git repo;請用 --path 指向目標專案的根目錄`); - } - - const git = (...args) => runGit(args, { cwd: path }); + const git = openGitRepo(path); checkClean(git, path); const hasOrigin = git('remote').split('\n').includes('origin'); diff --git a/scripts/lib.js b/scripts/lib.js index 9380f47..dca6f77 100644 --- a/scripts/lib.js +++ b/scripts/lib.js @@ -379,6 +379,22 @@ export function runGit(args, { cwd } = {}) { } } +/** + * 開一個目標專案的 git repo,回傳綁在它身上的執行器。 + * + * 碰目標專案 git 的腳本都從這裡進去:路徑不是 repo 時的錯誤碼要一致, + * 而「把 cwd 綁進 runGit」這件事寫第三遍就該收起來了。 + * + * @param {string} path 目標專案的根目錄 + * @returns {(...args: string[]) => string} 綁定 cwd 的 git 執行器 + */ +export function openGitRepo(path) { + if (!existsSync(join(path, '.git'))) { + throw new ScriptError('NOT_A_GIT_REPO', `${path} 不是 git repo;請用 --path 指向目標專案的根目錄`); + } + return (...args) => runGit(args, { cwd: path }); +} + // ── 四層前置檢查 ─────────────────────────────────────────────────── /** -- 2.53.0 From 1c6e7f85bbae5854e1aebd76a1aec0d576f8adfa Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 08:23:27 +0000 Subject: [PATCH 12/15] =?UTF-8?q?fix(lib):=20=E8=AE=93=20--key=3Dvalue=20?= =?UTF-8?q?=E7=9A=84=E5=80=BC=E5=8F=AF=E4=BB=A5=E6=9C=AC=E8=BA=AB=E4=BB=A5?= =?UTF-8?q?=20--=20=E9=96=8B=E9=A0=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit 訊息與 PR 描述裡出現 --flag 是常態,而原本的檢查對兩種寫法一視同仁:值只要以 -- 開頭就報「需要一個值」。空格分隔的寫法確實分不出「值」與「打錯的 flag」,但等號寫法 沒有這個歧義,不該一起擋掉。 這是實際撞到的:拿 commit-split 提交它自己時,--body 的內容第一行就是「--repo 與 --issue-repo 的差別」,整個 commit 因此做不出來。空格寫法的錯誤訊息現在會指路到等號寫法。 --- scripts/lib.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/scripts/lib.js b/scripts/lib.js index dca6f77..5b3f3cd 100644 --- a/scripts/lib.js +++ b/scripts/lib.js @@ -99,10 +99,19 @@ export function parseFlags(argv, spec = {}) { flags[name] = true; continue; } - if (eq === -1) i += 1; - const value = eq === -1 ? argv[i] : arg.slice(eq + 1); + if (eq !== -1) { + // --key=value:等號右邊就是值,即使它本身以 -- 開頭也沒有歧義。 + // commit 訊息、PR 描述這種內容裡出現 --flag 是常態,不該因此被當成打錯 flag。 + flags[name] = arg.slice(eq + 1); + continue; + } + i += 1; + const value = argv[i]; if (value === undefined || value.startsWith('--')) { - throw new ScriptError('MISSING_FLAG', `--${name} 需要一個值`); + throw new ScriptError( + 'MISSING_FLAG', + `--${name} 需要一個值;值本身以 -- 開頭時請改用 --${name}=值 的寫法`, + ); } flags[name] = value; } -- 2.53.0 From da5b670f295eb1cd42662d6bc4f73d29a1065723 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 08:23:27 +0000 Subject: [PATCH 13/15] =?UTF-8?q?test(script-contract):=20=E8=AE=93=20--ke?= =?UTF-8?q?y=3Dvalue=20=E7=9A=84=E5=80=BC=E5=8F=AF=E4=BB=A5=E6=9C=AC?= =?UTF-8?q?=E8=BA=AB=E4=BB=A5=20--=20=E9=96=8B=E9=A0=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit 訊息與 PR 描述裡出現 --flag 是常態,而原本的檢查對兩種寫法一視同仁:值只要以 -- 開頭就報「需要一個值」。空格分隔的寫法確實分不出「值」與「打錯的 flag」,但等號寫法 沒有這個歧義,不該一起擋掉。 這是實際撞到的:拿 commit-split 提交它自己時,--body 的內容第一行就是「--repo 與 --issue-repo 的差別」,整個 commit 因此做不出來。空格寫法的錯誤訊息現在會指路到等號寫法。 --- test/script-contract.test.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/script-contract.test.js b/test/script-contract.test.js index 1c0037a..1a54cd2 100644 --- a/test/script-contract.test.js +++ b/test/script-contract.test.js @@ -55,6 +55,28 @@ test('--repo 格式不是 owner/name 時失敗', async (t) => { assert.equal(json.error.code, 'BAD_REPO'); }); +test('--key=value 的值可以本身就以 -- 開頭', async (t) => { + // commit 訊息與 PR 描述裡出現 --flag 是常態;空格分隔的寫法分不出來,等號寫法可以 + const stub = await withStub(t); + + const { json } = await runScript('labels-list.js', ['--repo=--看起來像 flag 的值'], { + env: envFor(stub), + }); + + assert.equal(json.error.code, 'BAD_REPO', '要走到 repo 格式檢查,而不是被當成缺值'); +}); + +test('--key value 的值以 -- 開頭時仍然擋下,並指出等號寫法', async (t) => { + const stub = await withStub(t); + + const { json } = await runScript('labels-list.js', ['--repo', '--看起來像 flag 的值'], { + env: envFor(stub), + }); + + assert.equal(json.error.code, 'MISSING_FLAG'); + assert.match(json.error.message, /--repo=/); +}); + test('--key=value 與 --key value 兩種寫法等價', async (t) => { const stub = await withStub(t); -- 2.53.0 From 013947630f7134342106f0ac5a5ed5acc7aed754 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 08:23:28 +0000 Subject: [PATCH 14/15] =?UTF-8?q?test(sdlc-feat-assets):=20=E7=AC=AC?= =?UTF-8?q?=E4=B8=89=E6=AE=B5=E8=A3=9C=E4=B8=8A=E5=85=A9=E5=80=8B=20repo?= =?UTF-8?q?=20=E7=9A=84=E5=8D=80=E5=88=A5=E8=88=87=E9=87=8D=E8=B7=91?= =?UTF-8?q?=E7=9A=84=E8=A1=8C=E7=82=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 說明 --repo 與 --issue-repo 的差別、--base 要明講不讓腳本猜、重跑不會開出第二顆 PR, 以及提交中途失敗時不要自己回捲歷史。 --- test/sdlc-feat-assets.test.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/sdlc-feat-assets.test.js b/test/sdlc-feat-assets.test.js index 13e381d..b21771a 100644 --- a/test/sdlc-feat-assets.test.js +++ b/test/sdlc-feat-assets.test.js @@ -244,6 +244,28 @@ test('先開 PR 再停錶,且 PR 沒開成就不停錶', () => { assert.match(phase3, /工時要記在真的有做事的那段時間上/); }); +test('PR 的 repo 與議題的 repo 分開講清楚', () => { + assert.match(phase3, /--issue-repo/); + assert.match(phase3, /程式碼所在的 repo/); + assert.match(phase3, /工作包議題所在的/); + assert.match(phase3, /常常不是同一個/, '要說出為什麼需要兩個旗標'); +}); + +test('--base 要明講,不讓腳本猜', () => { + assert.match(phase3, /--base/); + assert.match(phase3, /不替你猜/); +}); + +test('重跑不會開出第二顆 PR,正本要說', () => { + assert.match(phase3, /重跑不會開出第二顆 PR/); + assert.match(phase3, /created/); +}); + +test('提交中途失敗的處置有交代,且明講不要自己回捲歷史', () => { + assert.match(phase3, /前面已經建立的那幾顆 commit/); + assert.match(phase3, /不要自己去回捲歷史/); +}); + test('兩支腳本都要求先試跑', () => { const dryRuns = phase3.match(/--dry-run/g) ?? []; assert.ok(dryRuns.length >= 2, `兩支寫入型腳本各要先試跑,只找到 ${dryRuns.length} 處`); -- 2.53.0 From 997d4ec2807b1660390f8dc3c25cee767a446a06 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 08:23:28 +0000 Subject: [PATCH 15/15] =?UTF-8?q?docs(sdlc-feat):=20=E7=AC=AC=E4=B8=89?= =?UTF-8?q?=E6=AE=B5=E8=A3=9C=E4=B8=8A=E5=85=A9=E5=80=8B=20repo=20?= =?UTF-8?q?=E7=9A=84=E5=8D=80=E5=88=A5=E8=88=87=E9=87=8D=E8=B7=91=E7=9A=84?= =?UTF-8?q?=E8=A1=8C=E7=82=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 說明 --repo 與 --issue-repo 的差別、--base 要明講不讓腳本猜、重跑不會開出第二顆 PR, 以及提交中途失敗時不要自己回捲歷史。 --- prompts/sdlc-feat.md | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/prompts/sdlc-feat.md b/prompts/sdlc-feat.md index 350fa42..d29a7ae 100644 --- a/prompts/sdlc-feat.md +++ b/prompts/sdlc-feat.md @@ -197,7 +197,11 @@ node scripts/commit-split.js --path <目標專案路徑> --type feat \ ``` `--type` 是**這次程式碼變更**的類型(`feat`/`fix`/`refactor`…);測試、文件與設定檔 -由腳本自己認出來,各自成批,不必也不能指定。 +由腳本自己認出來,各自成批,不必也不能指定。`--body` 寫「為什麼這樣做」,那一段會接在 +每一顆 commit 的首行之後——本 repo 的歷史靠它讀得懂。 + +某一批提交失敗時,錯誤會列出**前面已經建立的那幾顆 commit**。修掉原因之後重跑即可, +已建立的不會重複;不要自己去回捲歷史。 `--scope` 只在某一批有多個檔案時才需要:單檔那批的 scope 就是檔名。試跑會印出將建立的 每一顆 commit 與它各自的檔案,確認無誤後拿掉旗標再跑一次。 @@ -236,21 +240,29 @@ node scripts/commit-split.js ... --files scripts/claim.js,test/claim.test.js ### 14. 開 PR 並停錶 ``` -node scripts/pr-create.js --repo --head <分支名> \ - --body-file <描述檔> --index <工作包編號> --dry-run +node scripts/pr-create.js --repo <目標專案 owner/name> --head <分支名> \ + --base <來源分支> --body-file <描述檔> \ + --issue-repo <工作包議題的 owner/name> --index <工作包編號> --dry-run ``` -標題由腳本設為分支名,不必也不能另外指定。`--index` 是工作包議題編號,PR 開出去之後 -它會停掉那顆議題上的碼錶。 +`--repo` 是**程式碼所在的 repo**(PR 開在那裡),`--issue-repo` 是**工作包議題所在的 +repo**(錶停在那裡)。兩者常常不是同一個——議題在需求的 repo,程式碼在 `repos` 列的 +那幾個。同一個 repo 時 `--issue-repo` 可以省略。 + +`--base` 就是第一段問到的那支來源分支,要明講——腳本不替你猜 `master` 還是 `main`。 +標題由腳本設為分支名,不必也不能另外指定。 順序是**先開 PR 再停錶**,而且 PR 沒開成就不停錶——工時要記在真的有做事的那段時間上。 錶本來就沒在跑不算失敗(`碼錶已停` 會是 `false` 並附一句說明),PR 仍然開出去了。 +重跑不會開出第二顆 PR:同一個 head 已經有開著的 PR 就回傳它(`created` 為 `false`), +然後照樣停錶——那一步可能正是上次中斷的地方。 + ### 15. 回報 -- PR 的網址與編號、標題(等同分支名) +- PR 的網址與編號、標題(等同分支名),以及它是這次新開的還是接上既有的 - 建立了哪幾顆 commit -- 碼錶是否已停 +- 碼錶是否已停;沒停的話把腳本回的那句說明一起帶出來 - 議題上還有沒有沒勾完的待辦(理論上應該沒有;有的話要說出來) ## 邊界 -- 2.53.0