Compare commits

..
27 Commits
Author SHA1 Message Date
jiantw83andClaude Opus 5 d3ce364727 docs(sdlc-feat): description 跟著改成「備妥工作樹、起錶」
轉接檔的一行說明是從這裡取的,順序寫錯會讓人以為錶還是在領取那一步起。

議題 #40

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:30:00 +08:00
jiantw83andClaude Opus 5 0154cf59d4 fix(claim): 被碼錶擋下時明說停錶不會動到既有的工作樹
議題 #38 的使用者故事第 30 條:使用者常以為停錶等於放棄那顆工作包,於是寧可
不停——工時就記到別顆議題去了。碼錶只管時間、工作樹只管檔案,兩者互不相干,
這件事要在擋下來的當下就講,不能指望使用者自己推論。

領取與起錶會撞到同一個擋路理由,訊息收進 lib 只寫一份。順手收掉 review 指出的
三處:planWorktree 沒用到的 repo 參數、與 path.resolve 同名而誤導的區域函式、
以及只有 lib 自己用得到卻對外 export 的兩支路徑函式。

回滾補上最後一道:git 清不掉時把目錄本身也刪掉。那條路徑在這次執行之前不存在
(不存在正是建立的前提),裡面不可能有使用者的東西,而留著它下一次重跑會直接
撞上 WORKTREE_PATH_TAKEN——一次失敗的建立不該讓人從此開不了工。

議題 #40

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:29:12 +08:00
jiantw83andClaude Opus 5 4583a5f210 docs(sdlc-feat): 第一段改成領取、備妥工作樹、最後起錶
正本跟著實作走:分支那一步改成工作樹,新增起錶那一步排在它後面,
並把三處「不要自己決定」寫明——來源分支不存在時不自己換一支、路徑被佔住時
不自己刪、工作樹建不起來時不退回原地切分支。工作區不乾淨的處置整段拿掉:
工作樹本來就是為了讓未提交的變更不再擋路。

AGENTS.md 補上兩條邊界。git worktree add 一定會在目標 repo 的 .git/worktrees/
底下寫中繼資料,這是 git 的機制,無法避免——「不改目標專案」指的是專案的內容檔,
把這件事明說,免得下一個人以為實作違規。

議題 #40

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:29:12 +08:00
jiantw83andClaude Opus 5 74b4ca130e feat(timer): 碼錶移出領取,等工作樹建成之後才起
原本的順序是「放行 → 設 assignee 與標籤 → 起錶 → 處理分支」,而工作樹建立
失敗會中止整個領取——錶已經起了才失敗,使用者會被計一段什麼都沒做的時間,
而工時要準正是工時報表的立足點。

claim 只留領取鎖的兩件事(assignee 與標籤),起錶交給新的 timer.js,由流程
正本排在 branch-prep 之後。timer 已經跑在這顆議題上時什麼都不做:中斷後重跑
是它最常見的處境,重新起錶會把已經累積的時間切成兩段;跑在別顆上則照舊擋下,
不代勞停錶。

三支腳本讀碼錶的那段各留一份,趁這次收進 lib。

議題 #40

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:27:43 +08:00
jiantw83andClaude Opus 5 5b740c236b feat(branch-prep): 一律在獨立的工作樹上開工,不在原地切換分支
同一份 clone 上同時持有多顆工作包時,原地切分支有三種損耗,一種比一種難查:
未提交的變更擋路、建置產物跨分支混淆,以及 agent 讀到不屬於它那顆工作包的
程式碼——agent 是非同步的,它可能在分支已經被切走之後才去讀檔,而且不會察覺,
產出看起來完全合理,只是接錯了上下文。前兩種人會當場發現,第三種不會,
所以工作樹一律建立,不是「有衝突才用」。

建不起來就中止,不退回原地切分支:靜默降級會讓使用者以為自己在隔離環境裡,
其實在原地改。

分支與工作樹合併為一個原子動作(fetch 後一次 worktree add),並補上回滾——
git 在 worktree add 失敗時仍會把分支留下來,那是最難查的半成品:下一次重跑
會走到「目標分支已存在」那條路,起點從此不再是遠端的來源分支。

起點一律取自 origin/{來源分支},遠端沒有就中止,不退回本機同名分支;
本機分支可能落後好幾天,而這件事從輸出上完全看不出來。原「來源分支在遠端
已存在時 pull 而非重建」那條,用更強的方式達成同一個目的:根本不碰本機分支,
就沒有覆蓋他人進度的可能。

不設 upstream:此刻遠端還沒有這個新分支,--track 會把 upstream 指到來源分支,
之後 git pull 會把來源分支的提交拉進來。留給第一次 push -u 自然建立。

路徑由 owner/repo/分支名 正規化後取雜湊推導(lib 的 worktreePath),不查表、
不寫狀態檔,換機器算出來一樣。取雜湊而不是把斜線攤平成 -,是因為攤平會讓
feat/a-b/main 與 feat/a/b/main 撞成同一個目錄,而現行的分支命名規則恰好讓
這種形狀有機會出現。

議題 #40

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:27:43 +08:00
admin 03c368b6de 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>
2026-09-17 08:25:36 +00:00
jiantw83 997d4ec280 docs(sdlc-feat): 第三段補上兩個 repo 的區別與重跑的行為
說明 --repo 與 --issue-repo 的差別、--base 要明講不讓腳本猜、重跑不會開出第二顆 PR,
以及提交中途失敗時不要自己回捲歷史。
2026-09-17 08:23:28 +00:00
jiantw83 013947630f test(sdlc-feat-assets): 第三段補上兩個 repo 的區別與重跑的行為
說明 --repo 與 --issue-repo 的差別、--base 要明講不讓腳本猜、重跑不會開出第二顆 PR,
以及提交中途失敗時不要自己回捲歷史。
2026-09-17 08:23:28 +00:00
jiantw83 da5b670f29 test(script-contract): 讓 --key=value 的值可以本身以 -- 開頭
commit 訊息與 PR 描述裡出現 --flag 是常態,而原本的檢查對兩種寫法一視同仁:值只要以
-- 開頭就報「需要一個值」。空格分隔的寫法確實分不出「值」與「打錯的 flag」,但等號寫法
沒有這個歧義,不該一起擋掉。

這是實際撞到的:拿 commit-split 提交它自己時,--body 的內容第一行就是「--repo 與
--issue-repo 的差別」,整個 commit 因此做不出來。空格寫法的錯誤訊息現在會指路到等號寫法。
2026-09-17 08:23:27 +00:00
jiantw83 1c6e7f85bb fix(lib): 讓 --key=value 的值可以本身以 -- 開頭
commit 訊息與 PR 描述裡出現 --flag 是常態,而原本的檢查對兩種寫法一視同仁:值只要以
-- 開頭就報「需要一個值」。空格分隔的寫法確實分不出「值」與「打錯的 flag」,但等號寫法
沒有這個歧義,不該一起擋掉。

這是實際撞到的:拿 commit-split 提交它自己時,--body 的內容第一行就是「--repo 與
--issue-repo 的差別」,整個 commit 因此做不出來。空格寫法的錯誤訊息現在會指路到等號寫法。
2026-09-17 08:23:27 +00:00
jiantw83 95e6026950 refactor(lib): 開啟目標專案 git repo 的那幾行收進 lib
「路徑不是 repo 就報 NOT_A_GIT_REPO」加上「把 cwd 綁進 runGit」原本在 branch-prep 與
commit-split 各寫一份。錯誤碼要一致,而這件事寫第三遍就該收起來了。
2026-09-17 08:23:26 +00:00
jiantw83 fbc80f286f test(pr-create): 錶停在議題的 repo,並讓重跑不會開出第二顆 PR
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 那一支拿掉。測試結果的空話檢查改成整段每一行都是空話才擋,段落也改用
行首標題切,描述裡引用到「## 測試結果」這幾個字不會再讓檢查看錯地方。
2026-09-17 08:23:25 +00:00
jiantw83 30297cc49a fix(pr-create): 錶停在議題的 repo,並讓重跑不會開出第二顆 PR
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 那一支拿掉。測試結果的空話檢查改成整段每一行都是空話才擋,段落也改用
行首標題切,描述裡引用到「## 測試結果」這幾個字不會再讓檢查看錯地方。
2026-09-17 08:23:25 +00:00
jiantw83 8a6145ea14 test(commit-split): 改名時別漏掉舊檔的刪除,失敗時說出做到哪裡
git diff --name-only 預設偵測改名,只印出目的地那一個路徑。來源的刪除因此被漏掉——
留在 index 裡沒被提交,而腳本還回報成功,要等下一次跑才會發現工作區不乾淨。加 --no-renames。

某一批提交失敗時,錯誤現在會列出前面已經建立的那幾顆 commit。不回捲它們:那會動到使用者的
歷史,而那幾顆本身是好的;但一定要說出做到哪裡,否則重跑前得自己去翻 git log。

類型對照表補上目標專案常見的測試擺法:tests/、spec/、__tests__/,以及放在被測檔案旁邊的
user.test.js。先前只認 test/,目標專案的測試會被併進 feat 那一批。
2026-09-17 08:23:24 +00:00
jiantw83 f99adab245 fix(commit-split): 改名時別漏掉舊檔的刪除,失敗時說出做到哪裡
git diff --name-only 預設偵測改名,只印出目的地那一個路徑。來源的刪除因此被漏掉——
留在 index 裡沒被提交,而腳本還回報成功,要等下一次跑才會發現工作區不乾淨。加 --no-renames。

某一批提交失敗時,錯誤現在會列出前面已經建立的那幾顆 commit。不回捲它們:那會動到使用者的
歷史,而那幾顆本身是好的;但一定要說出做到哪裡,否則重跑前得自己去翻 git log。

類型對照表補上目標專案常見的測試擺法:tests/、spec/、__tests__/,以及放在被測檔案旁邊的
user.test.js。先前只認 test/,目標專案的測試會被併進 feat 那一批。
2026-09-17 08:23:23 +00:00
jiantw83 513dad7f6a test(sdlc-feat-assets): 加入第三段「提交與開立 PR」
PR 描述的八個段落與順序寫在正本裡,由 pr-create 擋;正本負責的是腳本擋不住的事:
測試結果要貼實際輸出而不是改寫成一句話、被擋下來時補真的內容而不是為了通過而拼湊、
以及跨兩個功能時用 --files 分兩次跑。

先開 PR 再停錶的理由也寫進去了:工時要記在真的有做事的那段時間上。
2026-09-17 08:23:23 +00:00
jiantw83 0b728ca270 feat(sdlc-feat): 加入第三段「提交與開立 PR」
PR 描述的八個段落與順序寫在正本裡,由 pr-create 擋;正本負責的是腳本擋不住的事:
測試結果要貼實際輸出而不是改寫成一句話、被擋下來時補真的內容而不是為了通過而拼湊、
以及跨兩個功能時用 --files 分兩次跑。

先開 PR 再停錶的理由也寫進去了:工時要記在真的有做事的那段時間上。
2026-09-17 08:23:22 +00:00
jiantw83 958b1f85e8 test(pr-create): 開立 PR 並停錶
標題等同分支名:reviewer 在列表上看到的就是分支,兩者對不上會找錯 PR。

描述的段落固定且順序固定,缺一段或順序不對就擋下,不自動補——補出來的段落是編的,
而 reviewer 會把它當成真的。

「測試結果」另外驗一次它不是空話。那一段是 reviewer 唯一能判斷「這東西真的跑過嗎」的
依據,寫「已測試通過」等於沒寫。判斷刻意很窄,只擋「整段只有一行,而那一行是已知的
偷懶寫法」——這一關要擋的是明顯沒跑過就交差,不是去評價別人的測試寫得夠不夠好。
沒有自動化測試時,寫得出可重現的手動驗證步驟就放行。

停錶排在 PR 開出去之後,而且只在 PR 真的建立了才停:工時要記在真的有做事的那段時間上。
錶本來就沒在跑不算失敗(Gitea 對此回 500)——PR 已經開出去了,把整件事報成失敗只會讓人
以為 PR 沒開成而重跑一次。
2026-09-17 08:23:22 +00:00
jiantw83 b5214ed0f1 feat(pr-create): 開立 PR 並停錶
標題等同分支名:reviewer 在列表上看到的就是分支,兩者對不上會找錯 PR。

描述的段落固定且順序固定,缺一段或順序不對就擋下,不自動補——補出來的段落是編的,
而 reviewer 會把它當成真的。

「測試結果」另外驗一次它不是空話。那一段是 reviewer 唯一能判斷「這東西真的跑過嗎」的
依據,寫「已測試通過」等於沒寫。判斷刻意很窄,只擋「整段只有一行,而那一行是已知的
偷懶寫法」——這一關要擋的是明顯沒跑過就交差,不是去評價別人的測試寫得夠不夠好。
沒有自動化測試時,寫得出可重現的手動驗證步驟就放行。

停錶排在 PR 開出去之後,而且只在 PR 真的建立了才停:工時要記在真的有做事的那段時間上。
錶本來就沒在跑不算失敗(Gitea 對此回 500)——PR 已經開出去了,把整件事報成失敗只會讓人
以為 PR 沒開成而重跑一次。
2026-09-17 08:23:21 +00:00
jiantw83 d6b44e0ba8 test(分批提交): 把變更依類型分批 commit
一個 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 的修改會少掉檔名的第一個字元。
2026-09-17 08:23:21 +00:00
jiantw83 bb886457fd feat(commit-split): 把變更依類型分批 commit
一個 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 的修改會少掉檔名的第一個字元。
2026-09-17 08:23:20 +00:00
admin 1c6fb8aaad Merge pull request 'feat/implement-and-tick-todos/main' (#44) from feat/implement-and-tick-todos/main into master
Reviewed-on: #44
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-17 07:42:51 +00:00
jiantw83andClaude Opus 5 8ea6a2a8b3 test(逐項實作): 覆蓋勾選的五種危險、兩份規則正本與正本第二段
勾選的測試全部繞著「會不會改錯行」打轉,五種都是 code review 抓出來的實際缺陷:
圍欄裡長得像 checkbox 的那一行不會被改到、不同段落的同一句話靠 --section 分得開、
大寫 [X] 重跑是 no-op、方框後沒有空白照樣勾得到、沒有方框的項目給的是指路的錯誤
而不是謊報已勾過。

另外釘住抽取端與勾選端的一致性:wp-extract 交得出來的每一種 raw,--tick 都要收得下。

patchOf 收進 helpers——它先前在三個測試檔裡各有一份一模一樣的定義。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 07:39:54 +00:00
jiantw83andClaude Opus 5 a45e981c95 feat(流程正本): sdlc-feat 加入第二段「逐項實作」
一項一項做完並即時勾選,讓議題頁的進度條隨時反映真實狀態。

過程不打斷:二十項待辦不按二十次同意,只印進度;也不為了勾選留留言——勾選改的是 body,
進度條自己會動,逐項留言會把議題洗版,reviewer 得從一堆「已完成第 N 項」裡找真正的討論。
真正該停下來問的只有三種,列出來了。

規則正本指名讀取,不在這裡複述——抄過來就會有兩份各自演化的規則。

中斷後重跑從 Gitea 的勾選狀態接續,不看任何本機檔案;重複勾選是安靜的 no-op,
所以不確定某一項有沒有勾到時直接再勾一次即可,不必先查。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 07:39:48 +00:00
jiantw83andClaude Opus 5 6e0fa92e73 feat(規則正本): 新增實作規範與註解格式對照表
兩份規則只存在於本 plugin 裡,由流程正本指名讀取,不寫進目標專案的任何檔案。

coding-standards.md 管規則:六種專案檔對應語言、認不出就停下來問;分層看職責不看目錄,
三層各寫功能/邏輯/資料源註解,服務層要標註呼叫的方法讓 reviewer 追得到呼叫鏈;
屬性的用途註解遞迴到每一層,並附真實資料範例,優先取自 MCP,推理來的要明講未經驗證
——不註明的話,會有人照著沒對過的格式寫解析。

comment-styles.md 只管格式:六種語言各一節,都附可照抄的方法註解與屬性註解範例。
Go 的「以識別字開頭」與 Python 的「docstring 在定義的下一行」各自點名,那是最常被
照抄成別的語言寫法的兩處。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 07:39:48 +00:00
jiantw83andClaude Opus 5 586b746b55 feat(issue-update): 以 --tick 勾待辦,並在沒東西可改時不送空的 PATCH
--tick 收抽取契約交出的那一整行 raw,--section 指出它在哪一個段落。四種擋下來的情況
各有錯誤碼,因為使用者的下一步不同:找不到(抽取結果過期,重抽)、同段落出現多次
(請改寫議題上重複的說法)、那一項沒有方框(去議題上補)、段落不存在(對照輸出確認)。
一律報錯不盲改——改壞了議題的進度條會說謊,而沒有人會去比對 body 的編輯紀錄。

--tick 的輸入只驗「是不是清單項」,不要求方框。抽取端會把忘了寫 checkbox 的項目也收成
一項待辦,那種 raw 要走到 tickLine 才拿得到「去議題上補成 checkbox」這句話;
在入口就擋掉,使用者只會得到一個看不出該怎麼辦的格式錯誤。

沒有任何欄位要改時整個 PATCH 都不送:空的 PATCH 會把議題的 updated_at 推新,在列表上
浮起來像是有人動過。這個判斷做在試跑分支之前——放在後面的話,試跑會預告一個實跑根本
不會發的請求,而那是最難查的那種落差。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 07:39:44 +00:00
jiantw83andClaude Opus 5 9582553c41 feat(議題解析): 勾選 checkbox 的精確替換,並統一清單項的文法
tickLine 把指定那一行的方框換成已勾,其餘一字不動。四件事決定它會不會靜靜改壞議題:

- **跳過圍欄。** 這是 issue-body.js 全檔的前提,而勾選是本檔唯一會寫回議題的路徑。
  工作包模板的架構圖就是一塊 fenced mermaid,裡面出現減號開頭的行是常態,
  把它當成待辦勾下去,改壞的是一張圖。
- **限定段落。** 待辦與整體驗收常有一模一樣的一句話,不限定就會回報「分不出來」,
  而使用者其實講得很清楚。理由與 upsertLineInSection 相同:弄錯的代價是靜靜改壞內容。
- **整行比對,認不出就交回 ambiguous。** 巢狀待辦底下常有一樣的驗收,賭第一個會讓
  進度條指著錯的那一項,而沒有人會去比對編輯紀錄。
- **[ ]、[x]、[X] 指的是同一行。** [X] 是合法的 GFM,Gitea 也渲染成已勾;只認小寫的話,
  中斷後重跑會硬失敗,錯誤訊息還會誣指「議題被改過」。

清單項的文法收斂成一份 LIST_ITEM,parseChecklistItem 與 tickLine 共用。先前兩端各寫一份,
鬆緊不一致:`- [ ]甲` 抽得出來卻勾不動,正本那句「一律用 wp-extract 給的 raw」就成了
做不到的指示。沒有方框的項目交回 no-checkbox,不再謊報「已經勾過」。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 07:39:43 +00:00
19 changed files with 2596 additions and 38 deletions
+173 -2
View File
@@ -1,5 +1,5 @@
name: sdlc-feat
description: 僅由 /sdlc-feat 指令叫用。領取一顆工作包、起錶、備妥分支,逐項實作並勾選待辦,最後分批提交並開立 PR。
description: 僅由 /sdlc-feat 指令叫用。領取一顆工作包、備妥工作樹、起錶,逐項實作並勾選待辦,最後分批提交並開立 PR。
# sdlc-feat
@@ -8,6 +8,10 @@ description: 僅由 /sdlc-feat 指令叫用。領取一顆工作包、起錶、
第一段**領取與開工準備**:把工作包安全地認領下來,備妥一棵屬於它的工作樹,然後開始計時。
這一段不改任何一行程式碼——它只負責讓後面的實作有個乾淨的起點。
第二段**逐項實作**:一項一項把待辦做完並即時勾選,讓議題頁的進度條隨時反映真實狀態。
第三段**提交與開立 PR**:把變更整理成讀得懂的歷史,開出 PR,停錶。
這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。
## 輸入
@@ -143,13 +147,180 @@ node scripts/timer.js --repo <owner/name> --index <編號> --dry-run
- 工作樹路徑,以及它是乾淨的、要先跑哪一行安裝指令
- 未處理留言數與未關閉的先決議題(若有)
## 第二段:逐項實作
### 8. 認出語言,讀規則正本
改任何一個檔案之前,先依專案檔認出這是什麼語言,再讀兩份規則正本:
- `references/coding-standards.md` — 分層判定與各層要寫什麼註解
- `references/comment-styles.md` — 該語言的註解格式
規則以那兩份為準,這裡不複述——抄過來就會有兩份各自演化的規則。只強調兩件最常被跳過的:
**認不出語言就停下來問、不要猜**,以及**規則只存在於本 plugin 裡**,
不寫進目標專案的任何檔案。
屬性的資料範例**優先從 MCP 取得**;取不到就以邏輯推理,並照 `comment-styles.md` 的寫法
在註解裡註明「由邏輯推理、未經驗證」。這句註明不能省,否則後面的人會照著沒對過的格式寫解析。
### 9. 一項一項做
**改的是工作樹裡的檔案**,路徑就是 `branch-prep` 印出來的 `worktree`,不是主工作區——
主工作區可能停在別的分支上,在那裡動手會把改動落到別顆工作包的分支去。
依 `wp-extract` 給的 `待辦` 順序做。每一項的做法:
1. 讀它底下的 `驗收`——那是「這一項做到什麼程度算完成」的定義。
2. 實作,照 `coding-standards.md` 的分層與註解規範。
3. 這一項的驗收都成立了,才算完成。
**過程不打斷。** 不要每做完一項就問一次「可以繼續嗎」——二十項待辦不該按二十次同意。
只印進度,例如 `[3/12] 已完成:解析九個段落`。
真正需要停下來問的只有三種:語言認不出來、待辦的意思有歧義、做下去會超出工作包的
`範圍邊界`。除此之外一路做完。
### 10. 做完一項就勾一項
```
node scripts/issue-update.js --repo <owner/name> --index <編號> \
--tick '<wp-extract 給的那一行 raw>' --section 待辦
```
`--tick` 收的是抽取契約交出的**那一整行 `raw`**,逐字包含縮排;它只把那一行的方框換成
已勾,議題其餘部分一字不動。待辦與它底下的驗收各自是一行,各勾各的。
`--section` 是那一項所在的段落:勾 `待辦` 裡的項目就給 `待辦`,勾 `整體驗收` 就給
`整體驗收`。**一定要給**——兩個段落常有一模一樣的一句話,不給就分不出要勾哪一個。
**不要自己拼那一行**,一律用 `wp-extract` 給的 `raw`。四種擋下來的情況都照實說,不要繞過去:
| 錯誤碼 | 意思 | 下一步 |
| --- | --- | --- |
| `RAW_NOT_FOUND` | 議題上找不到這一行 | 手上的抽取結果過期了(議題被改過);重跑 `wp-extract` 再試 |
| `RAW_AMBIGUOUS` | 這一行在同一個段落裡出現不只一次 | 分不出要勾哪個;請使用者把重複的那幾項改寫成看得出差別的說法 |
| `NOT_A_CHECKBOX` | 議題上那一項沒有方框 | 請使用者把它補成 `- [ ] …`;**不要自己改寫議題** |
| `SECTION_NOT_FOUND` | `--section` 的段落不存在 | 對照 `wp-extract` 的輸出確認段落名稱 |
**不要為了勾選在議題上留留言。** 勾選改的是 body,進度條自己會動;逐項留言會把議題洗版,
reviewer 得從一堆「已完成第 N 項」裡找真正的討論。
### 11. 中斷後重跑
進度完全由 Gitea 上的勾選狀態推導,**不看任何本機檔案**。重跑這一段時:
1. 重新 `wp-extract`,看 `待辦` 裡哪些 `done` 已經是 `true`。
2. 從第一個還沒勾的接下去做。
3. 已經勾過的項目再 `--tick` 一次是安靜的 no-op(回傳 `已經勾過: true`,不發 PATCH),
所以不確定某一項有沒有勾到時,直接再勾一次即可,不必先查。
### 12. 回報
全部待辦完成後印一份小結,不寫回議題:
- 幾項待辦、幾項驗收,全部勾選完成
- 改了哪些檔案,各屬於哪一層
- 有沒有待辦因為 `範圍邊界` 而被刻意不做
- 語言與註解格式用的是哪一份對照
- **哪些資料範例是推理來的**(MCP 取不到的那些),讓 reviewer 知道哪幾個格式還沒人對過
## 第三段:提交與開立 PR
### 13. 分批提交
全部待辦都勾完之後才進這一段。變更依類型分批:
```
node scripts/commit-split.js --path <工作樹路徑> --type feat \
--subject '<繁中描述>' [--scope <功能名>] --dry-run
```
`--path` 給的是第一段建出來的那棵**工作樹**——commit 要落在它的分支上。
`--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 的描述只說得清楚一件事,硬湊在一起就失去了分批的意義。
### 14. 寫 PR 描述
固定八個段落,順序不能換——reviewer 每次都在同一個位置找到要找的資訊:
1. **摘要** — 這個 PR 做完之後,什麼事變得可能。
2. **需求議題** — `#<編號>`。
3. **工作包議題** — `#<編號>`。
4. **變更內容** — 改了什麼。commit 一覽加上新增/修改的檔案。
5. **設計重點** — 為什麼這樣做。取捨與理由,不是實作步驟的複述。
6. **解決的問題** — 這次修掉了什麼。有具體觸發條件的就寫出來。
7. **影響的功能** — 誰會被影響、既有行為有沒有改變。
8. **測試結果** — 見下。
**「測試結果」放實際跑過的輸出**,原樣貼上,不要改寫成「已測試通過」——那句話看不出
跑過什麼,reviewer 沒辦法據以判斷。沒有自動化測試時,寫出 reviewer 自己能重現的手動
驗證步驟(跑什麼指令、看到什麼算對)。
`pr-create` 會擋下缺段落、順序不對、以及測試結果只有空話的描述。被擋下來時**補真的內容**,
不要為了通過而拼湊。
### 15. 開 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`),
然後照樣停錶——那一步可能正是上次中斷的地方。
### 16. 回報
- PR 的網址與編號、標題(等同分支名),以及它是這次新開的還是接上既有的
- 建立了哪幾顆 commit
- 碼錶是否已停;沒停的話把腳本回的那句說明一起帶出來
- 議題上還有沒有沒勾完的待辦(理論上應該沒有;有的話要說出來)
## 邊界
- 第一段**不改任何一行程式碼**、不勾待辦、不提交、不開 PR——那些是後面幾段的事。
- **不在主工作區動手。** 實作一律在 `branch-prep` 建出來的那棵工作樹裡進行。
- 第二段只實作與勾選。**不提交、不開 PR、不停錶**——那是第三段的事。
- 第三段不改任何一行程式碼。到這裡實作已經結束,要改就回第二段改完再來。
- **不在主工作區動手。** 第二段與第三段的每一個動作都在 `branch-prep` 建出來的那棵
工作樹裡進行,包含跑測試與 `--path`。
- 不把依賴、建置產物或 `.env` 這類機密檔案複製到工作樹裡,也不做連結——
兩棵工作樹共用同一份依賴,正好把工作樹要隔離的東西又接回去。
- 工作樹建不起來時中止,**不退回原地切分支**。
- 不把「已測試通過」這種空話寫進 PR 描述,也不為了通過檢查而拼湊內容。
- 不代替使用者決定 commit 的類型與描述;`--type` 與 `--subject` 都要是這次真的做了什麼。
- 不把實作規範或註解格式寫進目標專案的任何檔案。
- 不改與待辦無關的程式碼;順手想修的東西記下來說出來,不要摸進這次的變更裡。
- 不為了勾選在議題上留留言。
- 不自行建立標籤。缺「進行中」標籤時中止並請使用者建立。
- 不代替使用者停錶,也不在被鎖擋下時繞過去。
- 不替使用者決定來源分支。
+57
View File
@@ -0,0 +1,57 @@
# 實作規範
改目標專案的程式碼時照這份做。這份規則只存在於本 plugin 裡,**不寫入目標專案的任何檔案**
——目標專案的 `CLAUDE.md`、`AGENTS.md` 與設定檔一律不碰。
## 先認語言,再動手
改任何一個檔案之前,先從專案檔認出這是什麼語言:
| 專案檔 | 語言 |
| --- | --- |
| `*.csproj`、`*.sln` | C# |
| `composer.json` | PHP |
| `package.json` | JavaScript/TypeScript |
| `go.mod` | Go |
| `pom.xml`、`build.gradle` | Java |
| `pyproject.toml`、`setup.py` | Python |
認出來之後,對照 `references/comment-styles.md` 取得該語言的註解格式。
**認不出來就停下來問,不要猜。** 猜錯的代價是滿檔案格式不對的註解,比沒有註解更難清理。
同一個 repo 裡有多種語言時,以**正在改的那個檔案**所屬的語言為準。
## 分層看職責,不看目錄
目錄名稱會騙人:叫 `services/` 的資料夾裡常有一半是控制層。判斷依據一律是**這段程式在做什麼**。
| 層 | 怎麼認 | 要寫什麼註解 |
| --- | --- | --- |
| 控制層 | 對外的介面:HTTP handler、CLI 進入點、事件訂閱者、對外 API | **功能註解**——這個介面在做什麼、誰會呼叫它 |
| 服務層 | 所有邏輯:判斷、計算、流程編排 | **邏輯註解**——這段邏輯在解決什麼問題,並**標註它呼叫的所有方法** |
| 存取層 | 任何碰資料來源的東西:DB、外部 API、檔案、快取、訊息佇列 | **資料源註解**——資料從哪裡來、是哪一張表/哪一支 API |
服務層要標註呼叫的方法,是為了讓 reviewer **追得到呼叫鏈**:看一個方法就知道它會往下走到哪裡,
不必逐層點開。
## 屬性一律要有用途註解
每一個屬性都寫它的用途。**屬性本身是類別時遞迴處理**——巢狀結構的每一層都要有,
不能只註解最外層然後說「詳見該類別」。
用途註解要附**真實的資料範例**,讓人知道實際格式長什麼樣(是 `2026-09-17` 還是
`2026/09/17`,是 `TWD` 還是 `NTD`)。
範例的來源有優先順序:
1. **優先從 MCP 取得**——能連到真實資料來源時,取真的值。
2. 取不到就以邏輯推理,並**明確註明「由邏輯推理、未經驗證」**。
註明這件事不能省。未經驗證的範例本身有用,但讓人誤以為它經過驗證就會出事——
有人會照著那個格式寫解析。
## 邊界
- 不改與這次待辦無關的程式碼。看到順手想修的東西,記下來、說出來,不要摸進這次的變更裡。
- 不動目標專案的設定檔、CI 設定與相依版本,除非待辦本身就是在做那件事。
- 既有程式碼的註解不符合這份規範時,**只補你改到的那些**,不要順手重寫整個檔案。
+110
View File
@@ -0,0 +1,110 @@
# 註解格式對照表
各語言的註解怎麼寫。先用 `references/coding-standards.md` 的專案檔對照認出語言,再查這裡。
規範本身(哪一層寫什麼、屬性要附真實資料範例)在 `coding-standards.md`,這份只管**格式**。
## C#
XML 文件註解,`///` 起頭。屬性用 `<summary>`,範例寫在 `<example>` 或 summary 末尾。
```csharp
/// <summary>依訂單編號取回訂單主檔。呼叫 OrderRepository.FindById。</summary>
/// <param name="orderId">訂單編號,例如 "ORD-20260917-0012"</param>
public Order GetOrder(string orderId)
/// <summary>成立時間,ISO 8601 帶時區。例:2026-09-17T14:03:00+08:00</summary>
public DateTimeOffset CreatedAt { get; set; }
```
## PHP
PHPDoc,`/** */`。屬性用 `@var`,範例接在說明後面。
```php
/**
* 依訂單編號取回訂單主檔。呼叫 OrderRepository::findById()。
*
* @param string $orderId 訂單編號,例如 "ORD-20260917-0012"
*/
public function getOrder(string $orderId): Order
/** @var string 幣別代碼,ISO 4217。例:TWD */
private string $currency;
```
## JavaScript/TypeScript
JSDoc,`/** */`。TypeScript 本身已經有型別,所以註解只寫**用途與範例**,不要複述型別。
```js
/**
* 依訂單編號取回訂單主檔。呼叫 orderRepository.findById。
* @param {string} orderId 訂單編號,例如 "ORD-20260917-0012"
*/
async function getOrder(orderId)
/** 幣別代碼,ISO 4217。例:TWD */
currency;
```
## Go
`//` 起頭,**以被註解的識別字開頭**(Go 的慣例,`go doc` 會照這個排版)。
```go
// GetOrder 依訂單編號取回訂單主檔。呼叫 orderRepo.FindByID。
func GetOrder(orderID string) (*Order, error)
type Order struct {
// Currency 是幣別代碼,ISO 4217。例:TWD
Currency string
}
```
## Java
Javadoc,`/** */`。
```java
/**
* 依訂單編號取回訂單主檔。呼叫 OrderRepository#findById。
*
* @param orderId 訂單編號,例如 "ORD-20260917-0012"
*/
public Order getOrder(String orderId)
/** 幣別代碼,ISO 4217。例:TWD */
private String currency;
```
## Python
docstring,`"""..."""`,寫在定義的**下一行**(不是上一行)。屬性用行內 `#` 或 dataclass 的 docstring。
```python
def get_order(order_id: str) -> Order:
"""依訂單編號取回訂單主檔。呼叫 OrderRepository.find_by_id。
Args:
order_id: 訂單編號,例如 "ORD-20260917-0012"
"""
@dataclass
class Order:
currency: str # 幣別代碼,ISO 4217。例:TWD
```
## 未經驗證的範例怎麼標
範例取不到真實來源時,照該語言的格式把註明寫進註解裡,**不要另起一行 TODO**:
```js
/** 幣別代碼,ISO 4217。例:TWD(由邏輯推理、未經驗證) */
```
```python
currency: str # 幣別代碼,ISO 4217。例:TWD(由邏輯推理、未經驗證)
```
這句話要留在程式碼裡,讓後面的人知道這個格式還沒有人對過。
+2 -6
View File
@@ -40,7 +40,7 @@
*/
import { existsSync, realpathSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { ScriptError, main, parseFlags, parseRepo, runGit, worktreePath } from './lib.js';
import { ScriptError, main, openGitRepo, parseFlags, parseRepo, worktreePath } from './lib.js';
/** 需求描述的長度上限。超過就換一個短的說法,不要靠截斷。 */
const SLUG_MAX = 40;
@@ -78,11 +78,7 @@ main(async () => {
const branch = buildBranchName(source, flags.slug, flags.type);
const worktree = worktreePath(repo, branch);
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);
if (!git('remote').split('\n').includes('origin')) {
throw new ScriptError(
'NO_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;
}
+85 -6
View File
@@ -148,18 +148,17 @@ export function checklistInSection(body, section) {
* @returns {{indent: number, value: {text: string, done: boolean, raw: string}}|null}
*/
function parseChecklistItem(line) {
// [\s\S] 而非 . 的理由同 listSection:CRLF 的 body 行尾有 \r,. 不吃它。
// text 靠 trim 修掉 \r,raw 則原樣留著——它要逐字等於 body 裡的那一行。
const item = line.match(/^(\s*)(?:[-*+]|\d+\.)\s+([\s\S]*)$/);
// 文法與 tickLine 共用 LIST_ITEM:抽得出來的行,勾選端就要收得下。
// text 靠 trim 修掉 CRLF 的 \r,raw 則原樣留著——它要逐字等於 body 裡的那一行。
const item = LIST_ITEM.exec(line);
if (!item) return null;
const box = item[2].match(/^\[([ xX])\]\s*([\s\S]*)$/);
const text = (box ? box[2] : item[2]).trim();
const text = item[3].trim();
if (text === '') return null;
return {
indent: item[1].length,
value: { text, done: box ? box[1].toLowerCase() === 'x' : false, raw: line },
value: { text, done: item[2]?.toLowerCase() === '[x]', raw: line },
};
}
@@ -259,6 +258,86 @@ function isSeparator(cells) {
return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell));
}
/**
* 一行清單項的文法:符號或編號清單,後面可以有一個 checkbox。
*
* 全檔只有這一份定義。抽取端(parseChecklistItem)與勾選端(tickLine)若各寫一份,
* 遲早會鬆緊不一——抽得出來卻勾不動的那一行,會讓「一律用 wp-extract 給的 raw」
* 變成做不到的指示。
*/
const LIST_ITEM = /^(\s*)(?:[-*+]|\d+\.)\s+(\[[ xX]\])?\s*([\s\S]*)$/;
/**
* 這一行是不是清單項(有沒有 checkbox 都算)。
*
* `--tick` 用它驗輸入,而且刻意不要求 checkbox:抽取端會把「忘了寫 checkbox 的待辦」
* 也收成一項待辦,那種 raw 要走到 tickLine 才能得到「去議題上補成 checkbox」這句話,
* 在入口就擋掉只會回一個看不出該怎麼辦的格式錯誤。
* @param {string} line
* @returns {boolean}
*/
export function isListItem(line) {
return LIST_ITEM.test(line);
}
/**
* 勾起一行 checkbox:把 `raw` 那一行的方框換成已勾,其餘一字不動。
*
* 三件事都限定在目標段落之內、且跳過圍欄,理由與 upsertLineInSection 相同——
* 弄錯的代價是靜靜改壞別人的內容。勾選是這個檔案裡唯一會寫回議題的路徑,
* 而工作包模板的架構圖就是一塊 fenced mermaid:裡面出現減號開頭的行是常態,
* 把它當成待辦勾下去,改壞的是一張圖。
*
* 用整行精確比對而不是「找那段文字」,因為巢狀待辦底下常有一模一樣的驗收
* (兩項待辦各有一條「加上測試」)。認不出是哪一行時交回 ambiguous 讓呼叫端報錯,
* 不賭第一個——猜錯的話議題上的進度條會指著錯的那一項,而沒有人會去比對編輯紀錄。
*
* 勾選狀態與大小寫都不影響比對:`[ ]`、`[x]`、`[X]` 指的是同一行,
* 已經勾過就交回 already,讓中斷後重跑是安靜的 no-op 而不是失敗。
*
* 本函式不拋錯——它是純解析,錯誤碼由呼叫端決定。
*
* @param {string} body 議題 body
* @param {string} raw 抽取契約交出的原始 markdown 行,逐字包含縮排與行尾的 \r
* @param {string} [section] 限定在這個段落內找;省略時找全文(圍欄照樣不算)
* @returns {{status: 'ticked'|'already'|'not-found'|'ambiguous'|'no-checkbox'|'no-section', body?: string, line?: string, count: number}}
*/
export function tickLine(body, raw, section) {
const item = LIST_ITEM.exec(raw);
if (!item || item[2] === undefined) return { status: 'no-checkbox', count: 0 };
const rows = [...eachLine(body)];
const { start, end } = section === undefined
? { start: -1, end: rows.length }
: sectionBounds(rows, section);
if (section !== undefined && start === -1) return { status: 'no-section', count: 0 };
/** 同一行的三種寫法都指向它自己:比對時一律正規化成未勾的小寫版本 */
const normalize = (line) => line.replace(/\[[ xX]\]/, '[ ]');
const wanted = normalize(raw);
const ticked = raw.replace(/\[[ xX]\]/, '[x]');
const hits = [];
for (let i = start + 1; i < end; i += 1) {
if (rows[i].inFence) continue;
if (normalize(rows[i].line) === wanted) hits.push(i);
}
if (hits.length === 0) return { status: 'not-found', count: 0 };
if (hits.length > 1) return { status: 'ambiguous', count: hits.length };
const [at] = hits;
// 已勾與否看方框本身,不比整行字串:`[X]` 是合法的 GFM,Gitea 也渲染成已勾,
// 用字串相等判斷會把它當成還沒勾,於是重跑時硬把大寫改成小寫
if (LIST_ITEM.exec(rows[at].line)[2].toLowerCase() === '[x]') {
return { status: 'already', line: rows[at].line, count: 1 };
}
const lines = rows.map((row) => row.line);
lines[at] = ticked;
return { status: 'ticked', body: lines.join('\n'), line: ticked, count: 1 };
}
/**
* 在指定段落裡就地更新(或補上)一行「前綴+值」。
*
+108 -11
View File
@@ -12,10 +12,15 @@
* 也負責把圖解版總覽的網址寫回議題:連結以固定前綴獨佔一行,重跑時就地更新,
* 議題原本的 markdown 白話總覽一字不動——網頁是補充,不是取代。
*
* 以及勾待辦:`--tick` 收抽取契約交出的那一整行 `raw`,只把它的方框換成已勾。
* 認不出是哪一行、或那一行已經不在議題上時一律報錯,不盲改——改壞了議題的進度條會說謊,
* 而沒有人會去比對 body 的編輯紀錄。
*
* 用法:
* node scripts/issue-update.js --repo owner/name --index 12
* [--milestone <名稱>] [--due-date YYYY-MM-DD] [--estimate-days N]
* [--overview-url <網址>] [--host <網址>] [--dry-run]
* [--overview-url <網址>] [--tick '<raw 那一行>' [--section 待辦]]
* [--host <網址>] [--dry-run]
*/
import {
ScriptError,
@@ -28,7 +33,7 @@ import {
preflight,
resolveLogin,
} from './lib.js';
import { upsertLineInSection } from './issue-body.js';
import { isListItem, tickLine, upsertLineInSection } from './issue-body.js';
/** artifact 預設私有,組織外開不起來——這件事要跟著連結一起留在議題上 */
const PRIVACY_NOTE = '(此連結預設為私有,組織外無法開啟)';
@@ -36,7 +41,7 @@ const PRIVACY_NOTE = '(此連結預設為私有,組織外無法開啟)';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['repo', 'index'],
optional: ['milestone', 'due-date', 'estimate-days', 'overview-url', 'host'],
optional: ['milestone', 'due-date', 'estimate-days', 'overview-url', 'tick', 'section', 'host'],
booleans: ['dry-run'],
});
const repo = parseRepo(flags.repo);
@@ -44,11 +49,18 @@ main(async () => {
const dueDate = parseDueDate(flags['due-date']);
const days = parseDays(flags['estimate-days']);
const overviewUrl = parseOverviewUrl(flags['overview-url']);
const tick = parseTick(flags.tick, flags.section);
if (flags.milestone === undefined && dueDate === null && days === null && overviewUrl === null) {
if (
flags.milestone === undefined &&
dueDate === null &&
days === null &&
overviewUrl === null &&
tick === null
) {
throw new ScriptError(
'NOTHING_TO_UPDATE',
'至少要指定 --milestone、--due-date、--estimate-days 或 --overview-url 其中一個',
'至少要指定 --milestone、--due-date、--estimate-days、--overview-url 或 --tick 其中一個',
);
}
@@ -64,20 +76,38 @@ main(async () => {
if (dueDate !== null) {
payload.due_date = `${dueDate}T00:00:00Z`;
}
if (days !== null || overviewUrl !== null) {
const issue = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`);
let body = issue.body ?? '';
let current = null;
let 勾起的那一行 = null;
let 已經勾過 = false;
if (days !== null || overviewUrl !== null || tick !== null) {
current = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`);
let body = current.body ?? '';
if (days !== null) body = upsertLineInSection(body, '關聯', `估算人天:${days}`);
if (overviewUrl !== null) {
body = upsertLineInSection(body, '總覽', `圖解版總覽:${overviewUrl}${PRIVACY_NOTE}`);
}
// 沒變就不塞進 PATCH:無謂改寫 body 會在議題上留下一筆沒有內容的編輯紀錄
if (body !== issue.body) payload.body = body;
if (tick !== null) {
const result = applyTick(body, tick, flags.section);
body = result.body;
勾起的那一行 = result.line;
已經勾過 = result.已經勾過;
}
if (body !== current.body) payload.body = body;
}
// 全部都已經是現在這個樣子就不送:空的 PATCH 會把議題的 updated_at 推新,
// 在列表上浮起來像是有人動過。這個判斷要做在試跑分支之前,
// 否則試跑會預告一個實跑根本不會發的請求。
const noop = Object.keys(payload).length === 0;
const requests = noop ? [] : [{ method: 'PATCH', path, body: payload }];
if (flags['dry-run']) {
return { dryRun: true, repo, index, requests: [{ method: 'PATCH', path, body: payload }] };
return { dryRun: true, repo, index, 勾起的那一行, 已經勾過, requests };
}
if (noop) {
return { repo, index, updated: [], 勾起的那一行, 已經勾過, url: current.html_url };
}
const issue = expectOk(await giteaRequest(login, 'PATCH', path, { body: payload }), `PATCH ${path}`);
@@ -85,11 +115,78 @@ main(async () => {
repo,
index,
updated: Object.keys(payload),
勾起的那一行,
已經勾過,
url: issue.html_url,
};
});
/**
* 把 tickLine 的結果轉成這一層的錯誤碼。
* 認不出是哪一行就報錯而不是猜——精確替換的價值全在這裡。
*/
function applyTick(body, raw, section) {
const result = tickLine(body, raw, section);
if (result.status === 'no-section') {
throw new ScriptError(
'SECTION_NOT_FOUND',
`議題上沒有「${section}」這個段落;請確認 --section 的名稱與議題上的 \`## 標題\` 完全一致`,
);
}
if (result.status === 'no-checkbox') {
throw new ScriptError(
'NOT_A_CHECKBOX',
`議題上這一項沒有 checkbox,沒有方框可以勾:${raw.trim()};` +
'請先在議題上把它補成 `- [ ] …` 的寫法',
);
}
if (result.status === 'not-found') {
throw new ScriptError(
'RAW_NOT_FOUND',
`議題上找不到這一行:${raw.trim()};` +
'手上的抽取結果可能已經過期(議題被改過),請重新執行 wp-extract 再試',
);
}
if (result.status === 'ambiguous') {
throw new ScriptError(
'RAW_AMBIGUOUS',
`這一行在議題上出現了 ${result.count} 次,分不出要勾哪一個:${raw.trim()};` +
'請把議題上重複的那幾項改寫成看得出差別的說法,再重新抽取',
);
}
return {
body: result.status === 'ticked' ? result.body : body,
line: result.line,
已經勾過: result.status === 'already',
};
}
/**
* `--tick` 收的是一整行 raw,不是一段文字——精確替換的前提是它逐字等於議題上的那一行。
* 判斷用 issue-body 導出的同一份文法:抽取端收得下的,這裡就要收得下。
*/
function parseTick(value, section) {
if (value === undefined) {
if (section !== undefined) {
throw new ScriptError('MISSING_FLAG', '--section 是給 --tick 用的,單獨指定沒有作用');
}
return null;
}
if (value.includes('\n')) {
throw new ScriptError('BAD_RAW', '--tick 一次只勾一行,收到的內容夾帶了換行');
}
if (!isListItem(value)) {
throw new ScriptError(
'BAD_RAW',
`--tick 需要一整行清單項(例如「- [ ] 解析九個段落」),收到的是 ${value}`,
);
}
return value;
}
function parseDueDate(value) {
if (value === undefined) return null;
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
+28 -3
View File
@@ -141,10 +141,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;
}
@@ -421,6 +430,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;
}
+136
View File
@@ -0,0 +1,136 @@
/**
* 實作規範與註解格式對照表這兩份規則正本。
*
* 它們是 /sdlc-feat 第二段實際交付的東西:規範寫漏一條,產出的程式碼就少一種註解,
* 而那要等 reviewer 看到才會發現。對照表少一種語言,agent 就會開始猜格式。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { readReference } from './helpers/prompt-doc.js';
const standards = readReference('coding-standards');
const styles = readReference('comment-styles');
/**
* 切出一個 `## 標題` 段落。
* 以整行比對而不是 indexOf:`## Java` 是 `## JavaScript` 的前綴,
* 用 indexOf 會切到錯的那一節,而且切出來還是有內容的,錯得很安靜。
*/
function sectionOf(doc, heading) {
const lines = doc.split('\n');
const start = lines.findIndex((line) => line.trim() === `## ${heading}`);
if (start === -1) return null;
const rest = lines.slice(start + 1);
const end = rest.findIndex((line) => line.startsWith('## '));
return (end === -1 ? rest : rest.slice(0, end)).join('\n');
}
// ── 實作規範 ───────────────────────────────────────────────────────
test('六種專案檔都對得到語言', () => {
for (const file of [
'\\*\\.csproj',
'composer\\.json',
'package\\.json',
'go\\.mod',
'pom\\.xml',
'pyproject\\.toml',
]) {
assert.match(standards, new RegExp(file), `專案檔對照缺少 ${file}`);
}
});
test('認不出語言時要停下來問,而且說明了為什麼不猜', () => {
assert.match(standards, /認不出來就停下來問/);
assert.match(standards, /不要猜/);
assert.match(standards, /比沒有註解更難清理/, '要說出猜錯的代價,否則這條規則會被當成客套話');
});
test('分層判定明講看職責不看目錄', () => {
assert.match(standards, /看職責,不看目錄/);
assert.match(standards, /目錄名稱會騙人/);
});
test('三層各自要寫哪一種註解都寫明了', () => {
for (const [layer, comment] of [
['控制層', '功能註解'],
['服務層', '邏輯註解'],
['存取層', '資料源註解'],
]) {
const row = standards.split('\n').find((line) => line.includes(layer) && line.includes('|'));
assert.ok(row, `${layer}沒有出現在分層表裡`);
assert.match(row, new RegExp(comment), `${layer}要寫的是${comment}`);
}
});
test('服務層要標註呼叫的方法,並說明理由是追呼叫鏈', () => {
assert.match(standards, /標註它呼叫的所有方法/);
assert.match(standards, /追得到呼叫鏈/);
});
test('屬性註解要遞迴,而且明講不能只註解最外層', () => {
assert.match(standards, /屬性本身是類別時遞迴處理/);
assert.match(standards, /不能只註解最外層/);
});
test('資料範例的來源有優先序,且未經驗證時要註明', () => {
assert.match(standards, /優先從 MCP 取得/);
assert.match(standards, /由邏輯推理、未經驗證/);
assert.match(standards, /有人會照著那個格式寫解析/, '要說出不註明的代價');
});
test('明講不寫入目標專案的任何檔案', () => {
assert.match(standards, /不寫入目標專案的任何檔案/);
assert.match(standards, /CLAUDE\.md/);
});
// ── 註解格式對照表 ─────────────────────────────────────────────────
test('六種語言各有一節,且都附可照抄的程式碼範例', () => {
for (const [language, marker] of [
['C#', '///'],
['PHP', '@var'],
['JavaScript/TypeScript', 'JSDoc'],
['Go', 'go doc'],
['Java', 'Javadoc'],
['Python', 'docstring'],
]) {
const body = sectionOf(styles, language);
assert.ok(body, `對照表缺少 ${language}`);
assert.match(body, new RegExp(marker.replace(/[/#]/g, '\\$&')), `${language} 缺少 ${marker}`);
assert.match(body, /```/, `${language} 要有可照抄的範例,不要只用文字描述`);
}
});
test('每個語言的範例都同時示範了方法註解與屬性註解', () => {
const sections = styles.split(/^## /m).filter((s) => s.includes('```'));
for (const section of sections) {
const name = section.split('\n')[0].trim();
if (name === '未經驗證的範例怎麼標') continue;
assert.match(section, /例:|例如/, `${name} 的範例要示範「附真實資料範例」這件事`);
}
});
test('Go 的慣例(以識別字開頭)有被指出來,不是照抄別的語言', () => {
assert.match(sectionOf(styles, 'Go'), /以被註解的識別字開頭/);
});
test('Python 的 docstring 位置有講清楚在定義的下一行', () => {
const python = sectionOf(styles, 'Python');
assert.match(python, /下一行/);
assert.match(python, /不是上一行/, '這是最容易寫錯的一點,要明講');
});
test('未經驗證的註明怎麼寫,兩種語言各有一個可照抄的寫法', () => {
const section = sectionOf(styles, '未經驗證的範例怎麼標');
assert.match(section, /不要另起一行 TODO/);
assert.ok((section.match(/由邏輯推理、未經驗證/g) ?? []).length >= 2, '至少要有兩種語言的寫法');
});
// ── 兩份的分工 ─────────────────────────────────────────────────────
test('規範與格式分開:對照表不重複寫一遍規範', () => {
assert.match(styles, /這份只管\*\*格式\*\*/);
assert.match(standards, /comment-styles\.md/, '規範要指名去哪裡查格式');
});
+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, '');
});
+10
View File
@@ -103,3 +103,13 @@ export async function withStubGitea(t, routes) {
/** 把腳本指向這台假 Gitea 的環境變數 */
export const stubEnv = (stub) => ({ TEA_SDLC_API_BASE: stub.base, TEA_SDLC_TOKEN: 'stub-token' });
/**
* 找出腳本真正發出的那一個 PATCH。
* 前置檢查對 `issues/0` 的探針也是 PATCH,但它打在一顆不存在的議題上、不改動任何東西,
* 不該被當成腳本的寫入(見 lib.js 的 checkIssueWrite)。
* @returns {object|undefined} 沒發出寫入時為 undefined
*/
export function patchOf(stub) {
return stub.requests.find((r) => r.method === 'PATCH' && !r.path.endsWith('/issues/0'));
}
+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 }) };
}
/**
+376
View File
@@ -0,0 +1,376 @@
/**
* 勾選待辦:以抽取契約給的 `raw` 做精確字串替換。
*
* 這一支的全部價值在「只動目標那一行」。改壞的代價很安靜——議題上的進度條會說謊,
* 而沒有人會去比對 body 的編輯紀錄。所以三種危險各有測試:
* - 同一句話在 body 裡出現兩次(巢狀待辦底下常有一模一樣的驗收,例如「加上測試」)
* - `raw` 對不上(議題被人改過,手上的抽取結果已經過期)
* - 已經勾過了(中斷後重跑)
* 前兩種寧可報錯也不猜,第三種要安靜地當作沒事。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { runScript } from './helpers/run-script.js';
import { healthyRoutes, stubEnv as envFor, withStubGitea, patchOf } from './helpers/stub-gitea.js';
const REPO = 'plugins/tea-sdlc';
const INDEX = 12;
/**
* 一份有巢狀待辦的工作包 body。刻意埋了三個地雷:
* - 兩項驗收的文字一模一樣(同一段落內,真的分不出來)
* - 架構圖是 fenced mermaid,裡面有一行長得像 checkbox
* - 整體驗收裡有一行與待辦完全相同(不同段落,靠 --section 分得出來)
*/
const BODY = `## 架構圖
\`\`\`mermaid
flowchart TD
A[讀議題] --> B[勾待辦]
- [ ] 解析九個段落
\`\`\`
## 待辦
- [ ] 解析九個段落
- [ ] 缺段落回空值
- [ ] 加上測試
- [ ] 待辦解析成巢狀結構
- [ ] 加上測試
## 整體驗收
- [ ] 輸出欄位與契約完全一致
- [ ] 待辦解析成巢狀結構
`;
function routes(overrides = {}, { body = BODY } = {}) {
return healthyRoutes(REPO, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
status: 200,
body: { number: INDEX, title: '逐項實作並即時勾選待辦', body, html_url: 'https://example.com/12' },
},
[`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`]: (req) => ({
status: 200,
body: { number: INDEX, ...req.body, html_url: 'https://example.com/12' },
}),
...overrides,
});
}
const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options));
/**
* 把「待辦」段落裡的某一行換成指定寫法。
* 不直接對整份 BODY 做 replace:圍欄裡那一行排在待辦之前,會被換掉的是它。
*/
function withTodoLine(from, to) {
const at = BODY.indexOf('## 待辦');
return BODY.slice(0, at) + BODY.slice(at).replace(from, to);
}
const run = (args, stub) =>
runScript('issue-update.js', ['--repo', REPO, '--index', String(INDEX), ...args], {
env: envFor(stub),
});
// ── 精確替換 ───────────────────────────────────────────────────────
test('勾起指定的那一行,其餘一字不動', async (t) => {
const stub = await withStub(t);
const { code, json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub);
assert.equal(code, 0, JSON.stringify(json));
const body = patchOf(stub).body.body;
assert.match(body, /- \[x\] 解析九個段落/);
assert.equal(
body.replace('- [x] 解析九個段落', '- [ ] 解析九個段落'),
BODY,
'把那一個方框換回去之後,應該逐字等於原本的 body',
);
});
test('縮排的驗收項目也勾得到,縮排原樣保留', async (t) => {
const stub = await withStub(t);
const { code } = await run(['--tick', ' - [ ] 缺段落回空值', '--section', '待辦'], stub);
assert.equal(code, 0);
assert.match(patchOf(stub).body.body, /\n {2}- \[x\] 缺段落回空值\n/);
});
test('回報勾起來的是哪一行,讓呼叫端印進度', async (t) => {
const stub = await withStub(t);
const { json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub);
assert.equal(json.data.勾起的那一行, '- [x] 解析九個段落');
assert.equal(json.data.已經勾過, false);
});
// ── 文字重複時不誤傷 ───────────────────────────────────────────────
test('同一句話在 body 裡出現兩次時報錯,不賭第一個', async (t) => {
// 巢狀待辦底下常有一模一樣的驗收;猜錯的話,議題上的進度條會指著錯的那一項
const stub = await withStub(t);
const { code, json } = await run(['--tick', ' - [ ] 加上測試', '--section', '待辦'], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'RAW_AMBIGUOUS');
assert.match(json.error.message, /2/, '要說出它出現了幾次');
assert.equal(patchOf(stub), undefined, '分不出是哪一行就不要寫');
});
// ── raw 對不上 ─────────────────────────────────────────────────────
test('raw 不匹配時回錯誤,不盲改', async (t) => {
const stub = await withStub(t);
const { code, json } = await run(['--tick', '- [ ] 這一行議題上沒有', '--section', '待辦'], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'RAW_NOT_FOUND');
assert.match(json.error.message, /重新抽取|過期/, '要指出手上的抽取結果可能過期了');
assert.equal(patchOf(stub), undefined);
});
test('差一個空白也算對不上:精確替換就是要精確', async (t) => {
const stub = await withStub(t);
const { json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub);
assert.equal(json.error.code, 'RAW_NOT_FOUND');
});
// ── 冪等:中斷後重跑 ───────────────────────────────────────────────
test('已經勾過的項目不再動它,也不發 PATCH', async (t) => {
const body = withTodoLine('- [ ] 解析九個段落', '- [x] 解析九個段落');
const stub = await withStub(t, {}, { body });
const { code, json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub);
assert.equal(code, 0, '重跑不該失敗,那會讓中斷後的接續變成人工作業');
assert.equal(json.data.已經勾過, true);
assert.equal(patchOf(stub), undefined, '沒有變化就不要在議題上留下一筆空的編輯');
});
test('直接給已勾的那一行也算數,同樣是 no-op', async (t) => {
const body = withTodoLine('- [ ] 解析九個段落', '- [x] 解析九個段落');
const stub = await withStub(t, {}, { body });
const { code, json } = await run(['--tick', '- [x] 解析九個段落', '--section', '待辦'], stub);
assert.equal(code, 0);
assert.equal(json.data.已經勾過, true);
});
test('大寫的 [X] 重跑時也是安靜的 no-op,不是 RAW_NOT_FOUND', async (t) => {
// [X] 是合法的 GFM,Gitea 會把它渲染成已勾,wp-extract 也回報 done:true。
// 比對時若只認小寫,中斷後重跑會硬失敗,而錯誤訊息還會誣指「議題被改過」。
const body = withTodoLine('- [ ] 待辦解析成巢狀結構', '- [X] 待辦解析成巢狀結構');
const stub = await withStub(t, {}, { body });
const { code, json } = await run(['--tick', '- [X] 待辦解析成巢狀結構', '--section', '待辦'], stub);
assert.equal(code, 0, JSON.stringify(json));
assert.equal(json.data.已經勾過, true);
assert.equal(patchOf(stub), undefined);
});
// ── 輸入驗證 ───────────────────────────────────────────────────────
test('--tick 的內容根本不是清單項時擋下', async (t) => {
// 「是清單項但忘了寫方框」是另一種情況,錯誤碼不同——那種要指路去議題上補
const stub = await withStub(t);
const { json } = await run(['--tick', '解析九個段落'], stub);
assert.equal(json.error.code, 'BAD_RAW');
assert.match(json.error.message, /清單項/);
});
test('--section 沒有配 --tick 時說清楚它沒有作用', async (t) => {
const stub = await withStub(t);
const { json } = await run(['--section', '待辦', '--milestone', '第一階段'], stub);
assert.equal(json.error.code, 'MISSING_FLAG');
assert.match(json.error.message, /--tick/);
});
test('--tick 夾帶換行時擋下:一次只勾一行', async (t) => {
const stub = await withStub(t);
const { json } = await run(['--tick', '- [ ] 甲\n- [ ] 乙'], stub);
assert.equal(json.error.code, 'BAD_RAW');
});
// ── 圍欄與段落:不誤傷、也不假歧義 ─────────────────────────────────
test('圍欄裡長得像 checkbox 的那一行不算,不會被改到', async (t) => {
// 工作包模板的架構圖就是一塊 fenced mermaid,裡面出現減號開頭的行是常態。
// issue-body.js 全檔的前提是「圍欄裡的東西不是內容」,勾選是唯一會寫回去的路徑,
// 漏掉這件事就會靜靜改壞圖。
const stub = await withStub(t);
const { code } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub);
assert.equal(code, 0);
const body = patchOf(stub).body.body;
const fence = body.slice(body.indexOf('```mermaid'), body.indexOf('## 待辦'));
assert.match(fence, /- \[ \] 解析九個段落/, '圍欄裡那一行要原封不動');
});
test('不同段落有同一行時,--section 分得出來', async (t) => {
// 待辦與整體驗收各有一行「待辦解析成巢狀結構」,限定段落就不該是歧義
const stub = await withStub(t);
const { code, json } = await run(['--tick', '- [ ] 待辦解析成巢狀結構', '--section', '待辦'], stub);
assert.equal(code, 0, JSON.stringify(json));
const body = patchOf(stub).body.body;
const todo = body.slice(body.indexOf('## 待辦'), body.indexOf('## 整體驗收'));
const overall = body.slice(body.indexOf('## 整體驗收'));
assert.match(todo, /- \[x\] 待辦解析成巢狀結構/, '待辦那一行要被勾起');
assert.match(overall, /- \[ \] 待辦解析成巢狀結構/, '整體驗收那一行不該被動到');
});
test('整體驗收段落也勾得到,各勾各的', async (t) => {
const stub = await withStub(t);
const { code } = await run(['--tick', '- [ ] 待辦解析成巢狀結構', '--section', '整體驗收'], stub);
assert.equal(code, 0);
const body = patchOf(stub).body.body;
const todo = body.slice(body.indexOf('## 待辦'), body.indexOf('## 整體驗收'));
assert.match(todo, /- \[ \] 待辦解析成巢狀結構/, '待辦那一行不該被動到');
assert.match(body.slice(body.indexOf('## 整體驗收')), /- \[x\] 待辦解析成巢狀結構/);
});
test('--section 指到不存在的段落時報錯,不退回掃全文', async (t) => {
const stub = await withStub(t);
const { json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '沒有這一段'], stub);
assert.equal(json.error.code, 'SECTION_NOT_FOUND');
});
test('沒給 --section 時掃全文,但圍欄照樣不算', async (t) => {
const body = '## 待辦\n\n```\n- [ ] 圍欄裡的假待辦\n```\n\n- [ ] 真正的待辦\n';
const stub = await withStub(t, {}, { body });
const { code } = await run(['--tick', '- [ ] 圍欄裡的假待辦'], stub);
assert.equal(code, 1, '圍欄裡的行不是內容,找不到才對');
assert.equal(patchOf(stub), undefined);
});
// ── 抽取端與勾選端要對得上 ─────────────────────────────────────────
test('方框後面沒有空白也勾得到:抽取端收得下的,勾選端就要收得下', async (t) => {
// parseChecklistItem 的文法允許 `- [ ]甲`,wp-extract 會照樣交出它的 raw;
// 勾選端若比抽取端嚴格,正本那句「一律用 wp-extract 給的 raw」就變成做不到的事
const body = '## 待辦\n\n- [ ]沒有空白的那一項\n';
const stub = await withStub(t, {}, { body });
const { code, json } = await run(['--tick', '- [ ]沒有空白的那一項', '--section', '待辦'], stub);
assert.equal(code, 0, JSON.stringify(json));
assert.match(patchOf(stub).body.body, /- \[x\]沒有空白的那一項/);
});
test('議題上那一項根本沒有 checkbox 時,錯誤要說清楚而不是謊報已勾過', async (t) => {
// wp-extract 會把 `- 忘了寫 checkbox` 當成一項待辦(done:false),
// 但那一行沒有方框可以換。這時要說「去議題上補成 checkbox」,不能回報「已經勾過」
const body = '## 待辦\n\n- 忘了寫 checkbox 的待辦\n';
const stub = await withStub(t, {}, { body });
const { code, json } = await run(['--tick', '- 忘了寫 checkbox 的待辦', '--section', '待辦'], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'NOT_A_CHECKBOX');
assert.match(json.error.message, /補/, '要告訴使用者去議題上把它補成 checkbox');
});
// ── 與既有欄位共存 ─────────────────────────────────────────────────
test('--tick 可以和別的欄位一起送,共用同一個 PATCH', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/milestones`]: { status: 200, body: [{ id: 3, title: '第一階段' }] },
});
const { code } = await run(
['--tick', '- [ ] 解析九個段落', '--section', '待辦', '--milestone', '第一階段'],
stub,
);
assert.equal(code, 0);
const patch = patchOf(stub);
assert.match(patch.body.body, /- \[x\] 解析九個段落/);
assert.equal(patch.body.milestone, 3);
});
test('什麼都沒指定時仍然報 NOTHING_TO_UPDATE', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.equal(json.error.code, 'NOTHING_TO_UPDATE');
assert.match(json.error.message, /--tick/, '新欄位也要列進可用清單');
});
// ── --dry-run ─────────────────────────────────────────────────────
test('--dry-run 印出改完的 body,但不寫進去', async (t) => {
const stub = await withStub(t);
const { code, json } = await run(
['--tick', '- [ ] 解析九個段落', '--section', '待辦', '--dry-run'],
stub,
);
assert.equal(code, 0);
assert.equal(json.data.dryRun, true);
assert.match(json.data.requests[0].body.body, /- \[x\] 解析九個段落/);
assert.equal(patchOf(stub), undefined);
});
test('--dry-run 在已經勾過時要說「實跑不會發任何請求」', async (t) => {
// 試跑印出一個 PATCH、實跑卻什麼都不送,是最難查的那種落差
const body = withTodoLine('- [ ] 解析九個段落', '- [x] 解析九個段落');
const stub = await withStub(t, {}, { body });
const { json } = await run(
['--tick', '- [ ] 解析九個段落', '--section', '待辦', '--dry-run'],
stub,
);
assert.equal(json.data.已經勾過, true);
assert.deepEqual(json.data.requests, [], '沒有東西要改,預告的請求就該是空的');
});
test('--dry-run 遇到分不清的 raw 一樣報錯,不會等到實跑才發現', async (t) => {
const stub = await withStub(t);
const { json } = await run(['--tick', ' - [ ] 加上測試', '--section', '待辦', '--dry-run'], stub);
assert.equal(json.error.code, 'RAW_AMBIGUOUS');
});
// ── CRLF 的 body ───────────────────────────────────────────────────
test('CRLF 的 body 也勾得到,行尾的 \\r 不被吃掉', async (t) => {
// 議題只要在 Gitea 網頁上被編輯過就是 CRLF;wp-extract 交出的 raw 會連 \r 一起帶著
const body = BODY.replace(/\n/g, '\r\n');
const stub = await withStub(t, {}, { body });
const { code } = await run(['--tick', '- [ ] 解析九個段落\r', '--section', '待辦'], stub);
assert.equal(code, 0);
assert.match(patchOf(stub).body.body, /- \[x\] 解析九個段落\r\n/);
});
+20 -4
View File
@@ -5,7 +5,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { runScript } from './helpers/run-script.js';
import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js';
import { healthyRoutes, stubEnv as envFor, withStubGitea, patchOf } from './helpers/stub-gitea.js';
const REPO = 'plugins/tea-sdlc';
const INDEX = 12;
@@ -39,7 +39,6 @@ const run = (args, stub) =>
env: envFor(stub),
});
const patchOf = (stub) => stub.requests.find((r) => r.method === 'PATCH' && !r.path.endsWith('/0'));
// ── Milestone ─────────────────────────────────────────────────────
@@ -132,7 +131,24 @@ test('估算沒有變時不重寫 body', async (t) => {
await run(['--estimate-days', '3'], stub);
assert.equal('body' in patchOf(stub).body, false, '沒變就不該把 body 塞進 PATCH');
assert.equal(
patchOf(stub),
undefined,
'沒有任何欄位要改就整個 PATCH 都不發:空的 PATCH 會把議題的 updated_at 推新,'
+ '在列表上浮起來像是有人動過',
);
});
test('有別的欄位要改時照樣發 PATCH,但沒變的 body 不跟著被重寫', async (t) => {
const stub = await withStub(t, {}, {
body: '## 關聯\n\n需求議題:#1\n估算人天:3\n',
});
await run(['--estimate-days', '3', '--milestone', '第一階段'], stub);
const patch = patchOf(stub);
assert.equal(patch.body.milestone, 3);
assert.equal('body' in patch.body, false, '估算沒變,body 就不該被塞進去');
});
test('人天必須是正數', async (t) => {
@@ -305,7 +321,7 @@ test('連結沒變時不重寫 body', async (t) => {
await run(['--overview-url', 'https://example.com/a'], stub);
assert.equal('body' in patchOf(stub).body, false);
assert.equal(patchOf(stub), undefined, '連結沒變、也沒有別的欄位要改,就不發 PATCH');
});
test('不是網址時擋在打 Gitea 之前', async (t) => {
+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');
});
+1 -2
View File
@@ -7,7 +7,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { runScript } from './helpers/run-script.js';
import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js';
import { healthyRoutes, stubEnv as envFor, withStubGitea, patchOf } from './helpers/stub-gitea.js';
const REPO = 'plugins/tea-sdlc';
const INDEX = 12;
@@ -39,7 +39,6 @@ const run = (args, stub) =>
env: envFor(stub),
});
const patchOf = (stub) => stub.requests.find((r) => r.method === 'PATCH' && !r.path.endsWith('/0'));
// ── 以名稱指定 ─────────────────────────────────────────────────────
+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);
+189 -2
View File
@@ -9,8 +9,10 @@ import assert from 'node:assert/strict';
import { assertNeutralPrompt, readPrompt } from './helpers/prompt-doc.js';
const prompt = readPrompt('sdlc-feat');
/** 第一段的內容,避免把邊界段的字樣誤認成這一段的規則 */
const phase1 = prompt.slice(prompt.indexOf('## 第一段'), prompt.indexOf('## 邊界'));
/** 各段的內容分開切,避免把別段的字樣誤認成這一段的規則 */
const phase1 = 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');
@@ -130,3 +132,188 @@ test('邊界把第一段不做的事分開列,且明講不寫本機狀態檔',
assert.match(boundary, /不寫任何本機狀態檔/);
assert.match(boundary, /換一台機器或換一個 agent/, '要說明為什麼不留狀態檔');
});
// ── 第二段:逐項實作 ───────────────────────────────────────────────
test('第二段明講改的是工作樹裡的檔案,不是主工作區', () => {
assert.match(phase2, /改的是工作樹裡的檔案/);
assert.match(phase2, /落到別顆工作包的分支/, '要說明在主工作區動手的後果');
});
test('第二段指名兩份規則正本,且在改檔之前就要讀', () => {
assert.match(phase2, /references\/coding-standards\.md/);
assert.match(phase2, /references\/comment-styles\.md/);
const readAt = phase2.indexOf('coding-standards.md');
const implementAt = phase2.indexOf('### 9.');
assert.ok(readAt < implementAt, '讀規則要排在動手實作之前');
});
test('認不出語言就停下來問,不自行假設', () => {
assert.match(phase2, /認不出語言就停下來問/);
assert.match(phase2, /不要猜/);
});
test('不把規範寫進目標專案的檔案', () => {
assert.match(phase2, /不寫進目標專案的任何檔案/);
});
test('規則不在正本裡複述,只指名去哪裡讀', () => {
assert.match(phase2, /這裡不複述/);
assert.match(phase2, /兩份各自演化/, '要說出複述的代價,否則下一個人還是會抄過來');
});
test('資料範例優先取自 MCP,取不到要註明未經驗證', () => {
assert.match(phase2, /優先從 MCP 取得/);
assert.match(phase2, /由邏輯推理、未經驗證/);
});
test('回報要點出哪些範例是推理來的', () => {
const report = phase2.slice(phase2.indexOf('### 12.'));
assert.match(report, /哪些資料範例是推理來的/);
});
test('--section 一定要給,並說明不給會怎樣', () => {
assert.match(phase2, /--section/);
assert.match(phase2, /一定要給/);
assert.match(phase2, /分不出要勾哪一個/);
});
test('過程不打斷:不逐項徵求同意,只印進度', () => {
assert.match(phase2, /過程不打斷/);
assert.match(phase2, /不該按二十次同意/);
assert.match(phase2, /只印進度/);
assert.match(phase2, /\[3\/12\]/, '要給一個看得出長相的進度格式,不要只說「印進度」');
});
test('真正該停下來問的情況有列舉,不是一律不問', () => {
assert.match(phase2, /真正需要停下來問的只有三種/);
assert.match(phase2, /範圍邊界/);
});
test('勾選用 issue-update --tick,且明講要用抽取契約給的 raw', () => {
assert.match(phase2, /--tick/);
assert.match(phase2, /不要自己拼那一行/);
assert.match(phase2, /raw/);
});
test('四種勾不動的錯誤各自交代了下一步', () => {
for (const code of ['RAW_NOT_FOUND', 'RAW_AMBIGUOUS', 'NOT_A_CHECKBOX', 'SECTION_NOT_FOUND']) {
assert.match(phase2, new RegExp(code), `${code} 要出現在錯誤表裡`);
}
assert.match(phase2, /重跑 `wp-extract`/);
assert.match(phase2, /不要自己改寫議題/, '議題內容是使用者的,agent 不該代為修改');
});
test('不為了勾選留留言,並說明為什麼', () => {
assert.match(phase2, /不要為了勾選在議題上留留言/);
assert.match(phase2, /洗版/);
});
test('中斷後重跑從 Gitea 的勾選狀態接續,且不看本機檔案', () => {
assert.match(phase2, /不看任何本機檔案/);
assert.match(phase2, /done` 已經是 `true`|done.*true/);
assert.match(phase2, /no-op/, '要說明重複勾選是安全的,否則會有人先查再勾');
});
test('邊界把第二段不做的事也列出來', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /第二段只實作與勾選/);
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 的類型與描述/);
});