Merge pull request 'feat/commit-split-and-pr/main' (#45) from feat/commit-split-and-pr/main into master

Reviewed-on: #45
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
This commit was merged in pull request #45.
This commit is contained in:
2026-09-17 08:25:36 +00:00
10 changed files with 1514 additions and 14 deletions
+85
View File
@@ -10,6 +10,8 @@ description: 僅由 /sdlc-feat 指令叫用。領取一顆工作包、起錶、
第二段**逐項實作**:一項一項把待辦做完並即時勾選,讓議題頁的進度條隨時反映真實狀態。
第三段**提交與開立 PR**:把變更整理成讀得懂的歷史,開出 PR,停錶。
這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。
## 輸入
@@ -183,10 +185,93 @@ reviewer 得從一堆「已完成第 N 項」裡找真正的討論。
- 語言與註解格式用的是哪一份對照
- **哪些資料範例是推理來的**(MCP 取不到的那些),讓 reviewer 知道哪幾個格式還沒人對過
## 第三段:提交與開立 PR
### 12. 分批提交
全部待辦都勾完之後才進這一段。變更依類型分批:
```
node scripts/commit-split.js --path <目標專案路徑> --type feat \
--subject '<繁中描述>' [--scope <功能名>] --dry-run
```
`--type` 是**這次程式碼變更**的類型(`feat`/`fix`/`refactor`…);測試、文件與設定檔
由腳本自己認出來,各自成批,不必也不能指定。`--body` 寫「為什麼這樣做」,那一段會接在
每一顆 commit 的首行之後——本 repo 的歷史靠它讀得懂。
某一批提交失敗時,錯誤會列出**前面已經建立的那幾顆 commit**。修掉原因之後重跑即可,
已建立的不會重複;不要自己去回捲歷史。
`--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 <目標專案 owner/name> --head <分支名> \
--base <來源分支> --body-file <描述檔> \
--issue-repo <工作包議題的 owner/name> --index <工作包編號> --dry-run
```
`--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 的網址與編號、標題(等同分支名),以及它是這次新開的還是接上既有的
- 建立了哪幾顆 commit
- 碼錶是否已停;沒停的話把腳本回的那句說明一起帶出來
- 議題上還有沒有沒勾完的待辦(理論上應該沒有;有的話要說出來)
## 邊界
- 第一段**不改任何一行程式碼**、不勾待辦、不提交、不開 PR——那些是後面幾段的事。
- 第二段只實作與勾選。**不提交、不開 PR、不停錶**——那是第三段的事。
- 第三段不改任何一行程式碼。到這裡實作已經結束,要改就回第二段改完再來。
- 不把「已測試通過」這種空話寫進 PR 描述,也不為了通過檢查而拼湊內容。
- 不代替使用者決定 commit 的類型與描述;`--type` 與 `--subject` 都要是這次真的做了什麼。
- 不把實作規範或註解格式寫進目標專案的任何檔案。
- 不改與待辦無關的程式碼;順手想修的東西記下來說出來,不要摸進這次的變更裡。
- 不為了勾選在議題上留留言。
+2 -8
View File
@@ -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');
+221
View File
@@ -0,0 +1,221 @@
#!/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 { basename } from 'node:path';
import { ScriptError, main, openGitRepo, parseFlags } from './lib.js';
/** commit 訊息的類型。與既有 git 歷史一致,不另立新詞。 */
const TYPES = ['feat', 'fix', 'refactor', 'test', 'docs', 'chore', 'perf', 'style'];
/**
* 從檔案路徑看得出來的類型。由上往下比對,第一個命中的為準。
*
* 只列「看路徑就能確定」的那幾種。`scripts/`、`prompts/`、`references/`、`templates/`
* 都是產品本身,是新增還是修正得由做的人說,所以不在這張表裡——它們吃 `--type`。
*/
const BY_PATH = [
{
// 目標專案的測試未必放在 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',
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);
const git = openGitRepo(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 };
}
const done = [];
for (const { message, files } of commits) {
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 })) };
});
/**
* 列出工作區的變更檔案,含未追蹤與已刪除的。
*
* 刻意不用 `git status --porcelain`:它每一行的前兩欄是狀態碼,未 staged 的修改是
* 「空格 M」開頭,而 runGit 會 trim 掉輸出的前導空白——第一行的狀態欄會少一格,
* 切出來的檔名就少了第一個字元。改用兩個只印檔名的指令,不受 trim 影響。
*
* 未追蹤的那一份要單獨留著:提交時只有它們需要先 add。
* @returns {{changed: string[], untracked: Set<string>}}
*/
function changedFiles(git) {
// --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 !== '');
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;
}
+28 -3
View File
@@ -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;
}
@@ -379,6 +388,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 });
}
// ── 四層前置檢查 ───────────────────────────────────────────────────
/**
+236
View File
@@ -0,0 +1,236 @@
#!/usr/bin/env node
/**
* 開立 PR,然後停錶。
*
* 標題等同分支名:reviewer 在列表上看到的就是分支,兩者對不上會找錯 PR。
*
* 描述的段落固定且順序固定——reviewer 每次都在同一個位置找到要找的資訊。缺一段或順序
* 不對就擋下,不自動補:補出來的段落是編的,而 reviewer 會把它當成真的。
*
* 「測試結果」另外驗一次它不是空話。那一段是 reviewer 唯一能判斷「這東西真的跑過嗎」
* 的依據,寫「已測試通過」等於沒寫。沒有自動化測試時,寫可重現的手動驗證步驟也算數。
*
* 停錶排在 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 <分支> --base <分支>
* --body-file <描述檔> --index 13
* [--issue-repo owner/name] [--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', '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;
const index = parseIndex(flags.index);
const body = readBody(flags['body-file']);
// 描述先驗完再談寫入:不合格的描述不該等到實跑才發現
checkSections(body);
checkTestResult(body);
const pullsPath = `/repos/${repo}/pulls`;
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']) {
return {
dryRun: true,
repo,
issueRepo,
index,
head,
base,
title: head,
requests: [
{ method: 'POST', path: pullsPath, body: payload },
{ method: 'POST', path: stopPath, body: {} },
],
};
}
await preflight(login, repo);
// 冪等:同一個 head 已經有開著的 PR 就用它,重跑不會開出第二顆
const existing = await findOpenPull(login, repo, head);
const pull = existing ?? expectOk(
await giteaRequest(login, 'POST', pullsPath, { body: payload }),
`POST ${pullsPath}`,
);
// 錶只在 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 ? {} : { 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}`);
}
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 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 written = content.split('\n').filter((line) => line.trim() !== '');
// 每一行都是空話才算空話:混了實際輸出就放行,這一關不評價測試寫得好不好
const allEmptyTalk =
written.length > 0 &&
written.every((line) => EMPTY_TALK.has(line.trim().replace(/[。..]$/, '')));
if (content === '' || allEmptyTalk) {
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;
// Gitea 對「這顆議題上沒有碼錶在跑」回的是 500。那不是失敗——
// 只有這一種 500 能這樣看待,訊息對不上就照常拋,免得把真的伺服器錯誤吞掉。
if (response.status === 500 && /stopwatch/i.test(response.body?.message ?? '')) {
return false;
}
expectOk(response, `POST ${path}`);
return false;
}
+394
View File
@@ -0,0 +1,394 @@
/**
* 把變更分批 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: '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: '專案設定' },
{ 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'), '');
});
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) => {
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, '');
});
+3 -2
View File
@@ -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 }) };
}
/**
+425
View File
@@ -0,0 +1,425 @@
/**
* 開立 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';
/** PR 開在目標專案上 */
const REPO = 'myorg/myapp';
/** 工作包議題在另一個 repo 上——這是常態,不是特例 */
const ISSUE_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, {
[`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/${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', [...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);
// ── 標題與描述 ─────────────────────────────────────────────────────
test('PR 標題等同分支名', async (t) => {
const stub = await withStub(t);
const file = bodyFile('full', BODY);
const { code, json } = await runFull(file, stub);
assert.equal(code, 0, JSON.stringify(json));
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);
});
test('描述原樣送出,一個字都不改寫', async (t) => {
const stub = await withStub(t);
const file = bodyFile('verbatim', BODY);
await runFull(file, stub);
const pull = stub.requests.find((r) => r.method === 'POST' && r.path.endsWith('/pulls'));
assert.equal(pull.body.body, BODY);
});
test('--base 照給的值送出,不預設猜一個', async (t) => {
// 目標專案的開發分支可能叫 master、main 或 develop,猜錯會開到不存在的 base
const stub = await withStub(t);
const file = bodyFile('base', BODY);
await runFull(file, stub);
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/);
});
// ── 七段:少一段就擋 ───────────────────────────────────────────────
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 runFull(file, 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 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 = ['已測試通過', '測試通過', '全部通過', '測試皆已通過', '無'];
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 runFull(file, 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 runFull(file, 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 runFull(file, stub);
assert.equal(code, 0, JSON.stringify(json));
});
// ── 停錶 ───────────────────────────────────────────────────────────
test('PR 開完之後才停錶,順序不能反', async (t) => {
const stub = await withStub(t);
const file = bodyFile('stop', BODY);
await runFull(file, stub);
assert.deepEqual(posts(stub), [
`/api/v1/repos/${REPO}/pulls`,
`/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 runFull(file, 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/${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 runFull(file, 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 { 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.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);
});
// ── 輸入 ───────────────────────────────────────────────────────────
test('描述檔不存在時回可區分的錯誤碼', async (t) => {
const stub = await withStub(t);
const { json } = await run(
['--body-file', join(tmpRoot, '不存在的檔案.md'), '--index', String(INDEX)],
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 runFull(file, stub, ['--dry-run']);
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/${ISSUE_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 runFull(file, stub, ['--dry-run']);
assert.equal(json.error.code, 'MISSING_SECTION');
});
+22
View File
@@ -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);
+98 -1
View File
@@ -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,99 @@ 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('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} 處`);
});
test('邊界把第三段不做的事也列出來', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /第三段不改任何一行程式碼/);
assert.match(boundary, /不把「已測試通過」這種空話/);
assert.match(boundary, /不代替使用者決定 commit 的類型與描述/);
});