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
28 changed files with 3622 additions and 307 deletions
+6
View File
@@ -30,4 +30,10 @@
- **契約以議題為正本**:腳本的 flag 介面、JSON 輸出形狀、前置檢查與路徑定位規則,正本在[議題 #1](https://gitea.jsc.idv.tw/plugins/tea-sdlc/issues/1),實作時以該處為準;本檔不複寫,以免兩邊走鐘。
- **測試**:`npm test`(等同 `node --test`)。測試產生的暫存一律寫到 `.tmp/`,該目錄已被 git 忽略,也不會被測試探索掃到。
- **不改目標專案**:本 plugin 只讀目標專案的程式碼,不寫入目標專案的 `CLAUDE.md` 或任何設定檔。
唯一的例外是 git 自己的內部中繼資料——`git worktree add` 一定會在目標 repo 的
`.git/worktrees/` 底下寫東西,那是 git 的機制,無法避免,也不是專案的內容檔。
- **工作樹集中在家目錄**:每顆工作包的工作樹開在 `~/.tea-sdlc/worktrees/{hash}`,
路徑由 `owner/repo/分支名` 純函式推導(`scripts/lib.js` 的 `worktreePath`),
不查表也不寫狀態檔。不開在目標專案裡(會出現在它的 `git status`),
也不開在它的兄弟目錄(那個目錄結構屬於使用者)。
- **不自動觸發**:所有指令僅由使用者明確叫用;skill/command 的 `description` 統一以「僅由 /sdlc-xxx 指令叫用。」起頭。
+226 -15
View File
@@ -1,13 +1,17 @@
name: sdlc-feat
description: 僅由 /sdlc-feat 指令叫用。領取一顆工作包、起錶、備妥分支,逐項實作並勾選待辦,最後分批提交並開立 PR。
description: 僅由 /sdlc-feat 指令叫用。領取一顆工作包、備妥工作樹、起錶,逐項實作並勾選待辦,最後分批提交並開立 PR。
# sdlc-feat
拿一顆工作包,從領取到開出 PR。
第一段**領取與開工準備**:把工作包安全地認領下來,開始計時,備妥開工的分支。
第一段**領取與開工準備**:把工作包安全地認領下來,備妥一棵屬於它的工作樹,然後開始計時。
這一段不改任何一行程式碼——它只負責讓後面的實作有個乾淨的起點。
第二段**逐項實作**:一項一項把待辦做完並即時勾選,讓議題頁的進度條隨時反映真實狀態。
第三段**提交與開立 PR**:把變更整理成讀得懂的歷史,開出 PR,停錶。
這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。
## 輸入
@@ -41,8 +45,9 @@ node scripts/wp-extract.js --repo <owner/name> --index <編號>
node scripts/claim.js --repo <owner/name> --index <編號> --dry-run
```
確認無誤後拿掉旗標再跑一次。放行時它會設 assignee、貼「進行中」標籤、起錶——三件事
一起構成領取鎖,錶則是工時的來源。
確認無誤後拿掉旗標再跑一次。放行時它會設 assignee、貼「進行中」標籤——這兩件事一起
構成領取鎖。**錶不在這一步起**:它等工作樹建好之後才起(第 6 步)。工作樹建立失敗會
中止整個領取,錶要是先起了,使用者就被計了一段什麼都沒做的時間。
領取鎖有四種狀態,三種擋、一種放行。被擋下來時**不要繞過去**,照著錯誤碼告訴使用者
發生什麼事、下一步是什麼:
@@ -52,6 +57,9 @@ node scripts/claim.js --repo <owner/name> --index <編號> --dry-run
| 別人已經認領這顆 | `CLAIMED_BY_OTHER` | 改領別顆,或先跟對方確認 |
| 你的錶已經跑在這顆上 | `STOPWATCH_ON_THIS_ISSUE` | 這顆你正在做;要重新計時請先手動停錶 |
| 你的錶跑在別的議題上 | `STOPWATCH_ON_OTHER_ISSUE` | 多半是忘了停上一顆;先去停掉再回來 |
被錶擋下來時**要順帶說明停錶不會動到既有的工作樹**:碼錶只管時間、工作樹只管檔案。
不講清楚,使用者會以為停錶等於放棄那顆工作包,於是寧可不停——工時就記到別顆去了。
| 沒有鎖 | —— | 放行。自己已認領但沒起錶也算沒有鎖,那正是中斷後重跑的情形 |
碼錶一律由使用者自己停。哪一段時間該記在哪顆議題上只有他知道,代勞會把工時記錯地方。
@@ -68,6 +76,8 @@ node scripts/claim.js --repo <owner/name> --index <編號> --dry-run
但若這顆工作包明顯是某個既有功能分支的一部分,就建議那一支,並說明為什麼。
- **手動輸入** — 讓使用者自己填分支名。
不論哪一種,來源分支都必須**已經在遠端上**:工作樹的起點一律取自 `origin/{來源分支}`。
### 4. 把議題標題翻成英文
分支名的中段要用英文,中文會讓 CI 與 URL 出問題。把工作包議題的標題翻成
@@ -75,41 +85,242 @@ node scripts/claim.js --repo <owner/name> --index <編號> --dry-run
翻譯要保留原意而不是逐字直譯,寧可用一個更短的說法,也不要把長句截斷成看不懂的字串。
### 5. 備妥分支
### 5. 備妥工作樹
分支開在**工作包的 `repos` 列出的那些 repo** 上,不是開在本 plugin 的目錄裡。
工作樹開在**工作包的 `repos` 列出的那些 repo** 上,不是開在本 plugin 的目錄裡。
`repos` 只有一顆就用那一顆;**有多顆時逐一確認**要在哪幾個開分支,
再對每一個各跑一次 `branch-prep`,分支名在每個 repo 都相同。
```
node scripts/branch-prep.js --path <目標專案路徑> --source <來源分支> \
--slug <英文-kebab> [--type feat] --dry-run
node scripts/branch-prep.js --repo <owner/name> --path <目標專案路徑> \
--source <來源分支> --slug <英文-kebab> [--type feat] --dry-run
```
`--type` 只在來源是開發分支時要給(`feat`/`fix`/`chore`…);從功能分支長出時,
類型與需求描述沿用來源,不必也不能再指定。
試跑會印出將執行的 git 指令與算出來的分支名。確認無誤後拿掉旗標再跑一次。
試跑會印出將執行的 git 指令、算出來的分支名與工作樹路徑。確認無誤後拿掉旗標再跑一次。
它保證三件事,都是為了不弄丟別人的東西:工作區不乾淨時**先擋下來**,免得把不相干的
改動帶進這顆工作包的分支;來源分支在遠端已存在時是 **pull 而不是重建**;目標分支已經
存在時是**接上去而不是蓋掉**。
**不在原地切換分支,一律開一棵獨立的工作樹。** 每顆工作包有自己的目錄、自己的建置
產物、自己的未提交變更,彼此看不見對方。這件事對 agent 特別重要:它是非同步的,
可能在分支已經被切走之後才去讀檔,而它**不會察覺**自己讀到的是別顆工作包的內容——
產出看起來完全合理,只是接錯了上下文。
工作區不乾淨(`DIRTY_WORKTREE`)時,把 git 回報的檔案念給使用者聽,讓他決定要提交、
`git stash` 還是丟掉——**不要自己選**。
**工作樹一律建立,沒有例外。** 建不起來就照實中止,**不要改成在原地切分支**:
使用者會以為自己在隔離環境裡,其實在原地改。
### 6. 回報
工作樹路徑由 `owner/repo/分支名` 推導而得,印在輸出的 `worktree` 欄位。
**後面幾段的實作、測試與提交都在那棵工作樹裡做**,不要回到主工作區動手。
它保證三件事:
- **起點一律是遠端的來源分支**(`origin/{來源分支}`),不是本機同名分支——後者可能
落後好幾天。遠端沒有那一支時得到 `SOURCE_NOT_FOUND`,把訊息念給使用者,讓他決定
是先把來源分支推上去,還是改指定一個別的來源——**不要自己換一個**。
- **目標分支已經存在時接上去而不是蓋掉**;工作樹已經在了就沿用,不動裡面還沒提交的東西。
- **失敗時不留半成品**:不會出現有分支沒工作樹、或有工作樹沒分支的狀態。
推導出的路徑被別的東西佔住時(`WORKTREE_PATH_TAKEN`,多半是別的 clone 留下的),
把路徑念給使用者,請他確認裡面沒有還沒保存的東西再移除——**不要自己刪**。
工作樹是乾淨的:**沒有安裝依賴,也沒有任何建置產物**,`.env` 這類機密檔案更不會被
複製過去。把輸出的 `提示.安裝指令` 念給使用者,機密檔案請他自己放一份。
### 6. 起錶
工作樹建好之後才起錶:
```
node scripts/timer.js --repo <owner/name> --index <編號> --dry-run
```
確認無誤後拿掉旗標再跑一次。錶已經跑在這顆議題上時它什麼都不做——那正是中斷後重跑
的情形,重新起錶會把已經累積的時間切成兩段。
### 7. 回報
印出一份開工前的現況,不寫回議題:
- 工作包標題與網址、這一顆有幾項待辦
- 認領結果(是否本來就是自己的)、碼錶已起
- 來源分支、新分支名、分支是新建還是接上既有
- 工作樹路徑,以及它是乾淨的、要先跑哪一行安裝指令
- 未處理留言數與未關閉的先決議題(若有)
## 第二段:逐項實作
### 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——那些是後面幾段的事。
- 第二段只實作與勾選。**不提交、不開 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(由邏輯推理、未經驗證)
```
這句話要留在程式碼裡,讓後面的人知道這個格式還沒有人對過。
+194 -111
View File
@@ -1,6 +1,14 @@
#!/usr/bin/env node
/**
* 備妥開工的分支。
* 備妥開工的分支與工作樹。
*
* 每顆工作包在一棵屬於自己的工作樹上開工,不在原地切換分支。這樣同時持有幾顆工作包
* 都互不干擾:各自的未提交變更、各自的建置產物,而 agent 無論什麼時候去讀檔,
* 都只會讀到它該讀的那份程式碼——agent 是非同步的,它不會察覺自己讀到的是別顆工作包
* 的內容,產出看起來完全合理,只是接錯了上下文。
*
* 工作樹一律建立,沒有例外。建不起來就明確中止,不默默退回原地切分支:靜默降級會讓
* 使用者以為自己在隔離環境裡,其實在原地改。
*
* 命名規則(議題 #1 的正本):
* - 從開發分支長出 → `{類型}/{英文-kebab-需求描述}/main`
@@ -10,22 +18,29 @@
* 需求描述由議題標題翻譯——那是 agent 的事,不是腳本的事,所以這裡只收 `--slug`
* 並驗格式:英文 kebab、≤40 字元。中文分支名會讓 CI 與 URL 出問題,擋在建立之前。
*
* 三處「不弄丟別人的東西」:
* - 工作區不乾淨就不動手,免得把不相干的改動帶進這顆工作包的分支。
* - 來源分支在遠端已存在時 pull 而不是重建。
* - 目標分支已存在時接上去而不是從來源蓋掉。
* 三種情況都在任何 git 寫入之前判斷完:試跑印得出漂亮的計畫、實跑卻中途炸掉,
* 是最難查的那種落差。
* 建分支與建工作樹是同一個原子動作:先 `git fetch` 更新遠端引用,再以一次
* `git worktree add` 完成。起點一律取自 `origin/{來源分支}`,遠端沒有就中止,
* 不退回本機同名分支——那是靜默降級,而且後果隱蔽:使用者以為自己從最新的遠端狀態
* 開工,實際上起點可能落後好幾天。
*
* **不設 upstream**。此刻遠端還沒有這個新分支,`--track` 會把 upstream 指到*來源分支*,
* 之後 `git pull` 會把來源分支的提交拉進來,幾乎一定不是使用者要的。
* upstream 留給第一次 `push -u` 自然建立。
*
* 兩處「不弄丟別人的東西」:目標分支已存在時接上去而不是從來源蓋掉;推導出的路徑被
* 別的東西佔住時中止而不是硬蓋過去。兩者都在任何 git 寫入之前判斷完:試跑印得出
* 漂亮的計畫、實跑卻中途炸掉,是最難查的那種落差。
*
* 工作樹路徑由 `owner/repo/分支名` 純函式推導(見 lib 的 worktreePath),
* 不寫任何本機狀態檔:換機器或換 agent 都能接手,進度只從 Gitea 與 git 本身推導。
*
* 用法:
* node scripts/branch-prep.js --source <來源分支> --slug <英文-kebab>
* node scripts/branch-prep.js --repo <owner/name> --source <來源分支> --slug <英文-kebab>
* [--type feat] [--path <目標專案>] [--dry-run]
*/
import { existsSync } from 'node:fs';
import { existsSync, realpathSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { ScriptError, main, parseFlags, runGit } from './lib.js';
import { ScriptError, main, openGitRepo, parseFlags, parseRepo, worktreePath } from './lib.js';
/** 需求描述的長度上限。超過就換一個短的說法,不要靠截斷。 */
const SLUG_MAX = 40;
@@ -33,67 +48,63 @@ const SLUG_MAX = 40;
/** 功能分支長這樣:三段、前兩段非空。開發分支(master/main/develop)不合這個樣式。 */
const FEATURE_BRANCH = /^([a-z]+)\/([^/]+)\/([^/]+)$/;
/**
* 專案檔 → 把依賴裝起來的指令。
* 工作樹是乾淨的,這份對照表只用來提示使用者該跑什麼,腳本自己不執行安裝——
* 在別人的機器上裝東西應該是他自己的決定。偵測不到就不提,猜錯的指令比沒有更浪費時間。
*/
const INSTALL_HINTS = [
['package.json', 'npm install'],
['composer.json', 'composer install'],
['requirements.txt', 'pip install -r requirements.txt'],
['go.mod', 'go mod download'],
['Gemfile', 'bundle install'],
['Cargo.toml', 'cargo fetch'],
];
const CLEAN_WORKTREE =
'這是一棵乾淨的工作樹:沒有安裝依賴,也沒有任何建置產物。' +
'.env 這類機密檔案一律不自動複製,需要的話請自己放一份。';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['source', 'slug'],
required: ['repo', 'source', 'slug'],
optional: ['type', 'path'],
booleans: ['dry-run'],
});
const repo = parseRepo(flags.repo);
const path = flags.path ?? process.cwd();
const source = flags.source;
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 = openGitRepo(path);
if (!git('remote').split('\n').includes('origin')) {
throw new ScriptError(
'NO_ORIGIN',
`${path} 沒有 origin 遠端;工作樹的起點一律取自 origin/{來源分支},請先設定 origin`,
);
}
const git = (...args) => runGit(args, { cwd: path });
checkClean(git, path);
const hasOrigin = git('remote').split('\n').includes('origin');
/**
* 比對用全名 `refs/heads/<ref>`:`ls-remote --heads origin main` 的樣式比對吃的是
* ref 的尾段,而本 repo 的命名慣例讓每一支分支都以 `/main` 結尾——用短名比對,
* 拿 main 當開發分支的專案會整個誤判成「遠端已經有這一支」。
*/
const onRemote = (ref) =>
hasOrigin && git('ls-remote', '--heads', 'origin', `refs/heads/${ref}`).trim() !== '';
const onLocal = (ref) => git('branch', '--list', ref).trim() !== '';
const sourcePlan = syncPlan(source, onRemote(source), onLocal(source), {
missing: () => {
throw new ScriptError(
'SOURCE_NOT_FOUND',
`來源分支 ${source} 在本地與遠端都不存在;請確認分支名,或先把它推上遠端`,
);
},
});
const branchPlan = syncPlan(branch, onRemote(branch), onLocal(branch), {
// 目標分支不存在是常態:這就是開一支新分支
missing: () => ({ commands: [['checkout', '-b', branch]], 動作: '從來源建立' }),
onRemote: '接上遠端既有',
onLocal: '切換到本地既有',
});
const commands = [...sourcePlan.commands, ...branchPlan.commands];
const 來源 = { 位置: sourcePlan.位置, 動作: sourcePlan.動作 };
const 分支 = { 動作: branchPlan.動作 };
const plan = planWorktree(git, { source, branch, worktree });
const 報告 = { path, repo, source, branch, worktree, 分支: { 動作: plan.動作 } };
if (flags['dry-run']) {
return {
dryRun: true,
path,
source,
branch,
來源,
分支,
commands: commands.map((args) => `git ${args.join(' ')}`),
};
return { dryRun: true, ...報告, commands: plan.commands.map((args) => `git ${args.join(' ')}`) };
}
for (const args of commands) runGitStep(git, args, source);
if (plan.commands.length > 0) {
// 既有的本地分支不是這次建的,回滾時不能連它一起刪掉
const 分支本來就在 = onLocal(git, branch);
try {
for (const args of plan.commands) git(...args);
} catch (error) {
rollback(git, { worktree, branch, 保留分支: 分支本來就在 });
throw error;
}
}
return { path, source, branch, 來源, 分支 };
return { ...報告, 提示: { 訊息: CLEAN_WORKTREE, 安裝指令: installHints(worktree) } };
});
@@ -159,72 +170,144 @@ function isKebab(value) {
}
/**
* 工作區必須乾淨才開工。
*
* 未提交的改動與未追蹤的檔案都會跟著 checkout 走到新分支上,混進這顆工作包的 commit
* 裡;而目標分支已存在時,git 還會在 checkout 那一步才拒絕,屆時 fetch 與 merge
* 都已經跑掉了,留下做到一半的狀態。寧可一開始就擋。
*/
function checkClean(git, path) {
const dirty = git('status', '--porcelain');
if (dirty !== '') {
const files = dirty
.split('\n')
.map((line) => line.slice(3))
.join('、');
throw new ScriptError(
'DIRTY_WORKTREE',
`${path} 的工作區還有未處理的變更(${files});` +
'請先提交、暫存(git stash)或清掉,再來開分支',
);
}
}
/**
* 算出要把一支 ref 弄到手需要哪幾個 git 指令。
*
* 來源分支與目標分支的處理是同一個形狀——遠端有就 pull、只有本地就切過去——
* 差別只在「兩邊都沒有」時怎麼辦,所以那一段由呼叫端給。
* 算出要把這棵工作樹弄到手需要哪幾個 git 指令。
*
* 分成「算」與「做」兩段,`--dry-run` 才能印出真正將執行的 git 指令,
* 而不是另外維護一份描述——兩邊分開寫就會走鐘。
* 而不是另外維護一份描述——兩邊分開寫就會走鐘。會擋的判斷全在這一段裡完成,
* 所以試跑與實跑在同一個地方被擋下來。
*
* @param {(ref: string) => {commands: string[][], 動作: string}} handlers.missing 兩邊都沒有時
* @returns {{commands: string[][], 動作: string}} commands 為空代表工作樹已經在了
*/
function syncPlan(ref, onRemote, onLocal, handlers) {
if (onRemote) {
// 遠端已經有了就 pull,不重建:別人推上去的進度要帶進來
const commands = [['fetch', 'origin', ref]];
commands.push(onLocal ? ['checkout', ref] : ['checkout', '-b', ref, `origin/${ref}`]);
if (onLocal) commands.push(['merge', '--ff-only', `origin/${ref}`]);
return { commands, 位置: '遠端', 動作: handlers.onRemote ?? 'pull' };
function planWorktree(git, { source, branch, worktree }) {
const 既有 = listWorktrees(git).find((entry) => samePath(entry.path, worktree));
const 目錄還在 = existsSync(worktree);
if (既有 && 目錄還在) {
if (既有.branch !== branch) {
throw new ScriptError(
'WORKTREE_PATH_TAKEN',
`${worktree} 已經是 ${既有.branch} 的工作樹;請先 git worktree remove 它再重跑`,
);
}
if (onLocal) {
return {
commands: [['checkout', ref]],
位置: '本地',
動作: handlers.onLocal ?? '用本地既有',
};
// 冪等:中斷重跑時接上既有那一棵,不碰裡面還沒提交的東西
return { commands: [], 動作: '沿用既有工作樹' };
}
return handlers.missing(ref);
if (!既有 && 目錄還在) {
throw new ScriptError(
'WORKTREE_PATH_TAKEN',
`${worktree} 已經有東西了,但它不是這個 repo 的工作樹(可能是別的 clone 留下的);` +
'請確認裡面沒有還沒保存的東西之後移除它,再重跑',
);
}
// 起點一律取自遠端:本機同名分支可能落後好幾天,靜默拿它當起點的後果太隱蔽
if (!onRemote(git, source)) {
throw new ScriptError(
'SOURCE_NOT_FOUND',
`遠端沒有來源分支 ${source};請先把它推上去(git push origin ${source}),` +
'或改指定一個已經存在於遠端的來源分支',
);
}
// 目錄被刪掉但中繼資料還在時先清乾淨,否則 git 會說這條路徑已經註冊過
const commands = 既有 ? [['worktree', 'prune']] : [];
commands.push(['fetch', 'origin']);
if (onLocal(git, branch)) {
// 已經有的分支接上去,不從來源蓋掉:上面可能有做到一半的進度
commands.push(['worktree', 'add', worktree, branch]);
return { commands, 動作: '接上本地既有' };
}
if (onRemote(git, branch)) {
commands.push(['worktree', 'add', '--no-track', '-b', branch, worktree, `origin/${branch}`]);
return { commands, 動作: '接上遠端既有' };
}
commands.push(['worktree', 'add', '--no-track', '-b', branch, worktree, `origin/${source}`]);
return { commands, 動作: '從來源建立' };
}
/**
* 跑一個 git 步驟,並把已知會發生的失敗換成看得懂的錯誤碼。
* `merge --ff-only` 失敗幾乎都是同一件事:本地有沒推上去的 commit,而遠端也往前走了。
* 原始的 git 訊息說得不夠白,使用者需要知道下一步是 rebase 還是先推。
* 這個 repo 目前有哪幾棵工作樹。
* `--porcelain` 的輸出是以空行分隔的區塊,每塊第一行是 `worktree <路徑>`,
* 分支則是 `branch refs/heads/<名字>`;detached 的工作樹沒有 branch 那一行。
*/
function runGitStep(git, args, source) {
function listWorktrees(git) {
return git('worktree', 'list', '--porcelain')
.split('\n\n')
.map((block) => {
const path = block.match(/^worktree (.+)$/m)?.[1];
const branch = block.match(/^branch refs\/heads\/(.+)$/m)?.[1] ?? null;
return path ? { path, branch } : null;
})
.filter(Boolean);
}
/**
* 兩條路徑指的是不是同一個地方。
* git 印出來的是解析過符號連結的真實路徑,而推導出來的那一條可能經過連結
* (家目錄本身就常是一條連結),逐字比對會把同一棵工作樹判成兩棵。
*/
function samePath(a, b) {
return a === b || realOrSelf(a) === realOrSelf(b);
}
/** 解析得出真實路徑就用它,路徑還不存在時退回原字串 */
function realOrSelf(path) {
try {
return git(...args);
} catch (error) {
if (args[0] === 'merge') {
throw new ScriptError(
'SOURCE_DIVERGED',
`${source} 的本地與遠端已經分歧,無法直接快轉;` +
'請先把本地的 commit 推上去或 rebase 到遠端之後,再執行一次',
);
}
throw error;
return realpathSync(path);
} catch {
return path;
}
}
/**
* 遠端有沒有這一支分支。
*
* 比對用全名 `refs/heads/<ref>`:`ls-remote --heads origin main` 的樣式比對吃的是
* ref 的尾段,而本 repo 的命名慣例讓每一支分支都以 `/main` 結尾——用短名比對,
* 拿 main 當開發分支的專案會整個誤判成「遠端已經有這一支」。
*/
function onRemote(git, ref) {
return git('ls-remote', '--heads', 'origin', `refs/heads/${ref}`).trim() !== '';
}
function onLocal(git, ref) {
return git('branch', '--list', ref).trim() !== '';
}
/**
* 建立失敗時把半成品清掉。
*
* `git worktree add` 失敗時仍會把新分支留下來,而那是最難查的半成品:下一次重跑會走到
* 「目標分支已存在」那條路,起點從此不再是遠端的來源分支。本來就存在的分支不能碰——
* 上面可能有別人的進度。
*/
function rollback(git, { worktree, branch, 保留分支 }) {
quietly(git, ['worktree', 'remove', '--force', worktree]);
quietly(git, ['worktree', 'prune']);
if (!保留分支) quietly(git, ['branch', '-D', branch]);
// git 清不乾淨時把目錄本身也清掉:這條路徑在這次執行之前不存在(不存在是建立的前提),
// 裡面不可能有使用者的東西;留著它下一次重跑會直接撞上 WORKTREE_PATH_TAKEN
try {
rmSync(worktree, { recursive: true, force: true });
} catch {
// 連目錄都刪不掉就只能留著:原本的錯誤比清理的錯誤重要
}
}
/** 清理用的 git:失敗了也不能蓋掉真正的錯誤訊息,那才是使用者要看的東西。 */
function quietly(git, args) {
try {
git(...args);
} catch {
// 清不掉就算了:原本的錯誤比清理的錯誤重要
}
}
/**
* 這棵工作樹要怎麼把依賴裝起來。
* 偵測不到就回空陣列,不亂猜——猜錯的指令比沒有指令更浪費時間。
*/
function installHints(worktree) {
return INSTALL_HINTS.filter(([file]) => existsSync(join(worktree, file))).map(([, command]) => command);
}
+14 -18
View File
@@ -1,10 +1,13 @@
#!/usr/bin/env node
/**
* 領取一顆工作包:上鎖、貼標籤、起錶。
* 領取一顆工作包:上鎖、貼標籤。
*
* 鎖用 assignee 加標籤,不用碼錶——Gitea 只讓人讀自己的碼錶(`/user/stopwatches`),
* 看不到別人的錶,拿它當鎖會漏判。碼錶在這裡只有一個用途:發現自己忘了停掉上一顆。
*
* **錶不在這一步起**。它等工作樹建好之後才由 timer.js 起動(見 branch-prep.js):
* 工作樹建立失敗會中止整個領取,錶要是先起了,使用者就被計了一段什麼都沒做的時間。
*
* 四種狀態的處置:
* - 他人已認領 → 擋。不會兩個人做同一件事。
* - 自己的錶跑在本議題 → 擋。這顆你已經在做了,別重複起錶。
@@ -24,12 +27,15 @@ import {
fetchIssue,
giteaRequest,
listLabels,
listStopwatches,
main,
parseFlags,
parseIndex,
parseRepo,
preflight,
resolveLogin,
stopwatchElsewhere,
stopwatchOnIssue,
} from './lib.js';
/** 領取鎖的另一半。本 plugin 不自動建立標籤,這個名字要在 repo 上先存在。 */
@@ -84,9 +90,6 @@ main(async () => {
});
labels.push(IN_PROGRESS);
}
// 錶最後才起:前面任一步失敗時,不該留下一顆還在跑的碼錶
planned.push({ method: 'POST', path: `${issuePath}/stopwatch/start`, body: {} });
if (dryRun) {
return { dryRun: true, repo, index, title: issue.title, requests: planned, 已認領過 };
}
@@ -102,7 +105,8 @@ main(async () => {
url: issue.html_url,
assignee: me,
labels,
碼錶中: true,
// 鎖上好了,錶還沒起:它等工作樹建好之後才由 timer.js 起動
碼錶中: false,
已認領過,
};
});
@@ -128,25 +132,17 @@ function checkClaimable(assignees, me, index) {
* 跑在別的議題是「你忘了停掉那一顆」。
*/
async function checkNoStopwatch(login, repo, index) {
const path = '/user/stopwatches';
const watches = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? [];
const watches = await listStopwatches(login);
if (watches.length === 0) return;
const here = watches.find(
(watch) => `${watch.repo_owner_name}/${watch.repo_name}` === repo && watch.issue_index === index,
);
if (here) {
if (stopwatchOnIssue(watches, repo, index)) {
throw new ScriptError(
'STOPWATCH_ON_THIS_ISSUE',
`你的碼錶已經跑在議題 #${index} 上,這顆你正在做;` +
'若要重新計時,請先在 Gitea 上手動停錶再執行一次',
'若要重新計時,請先在 Gitea 上手動停錶再執行一次——' +
'停錶只停計時,不會動到你既有的工作樹',
);
}
const elsewhere = watches[0];
throw new ScriptError(
'STOPWATCH_ON_OTHER_ISSUE',
`你的碼錶正跑在 ${elsewhere.repo_owner_name}/${elsewhere.repo_name} 的議題 ` +
`#${elsewhere.issue_index} 上,領取前請先手動停錶,否則工時會記到那一顆去`,
);
throw stopwatchElsewhere(watches[0], '領取');
}
+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)) {
+118 -3
View File
@@ -12,6 +12,7 @@
* 外部相依集中在 giteaRequest 與 runGit 兩個函式,測試才有地方替身。
*/
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { accessSync, constants, existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
@@ -51,6 +52,47 @@ export function promptsDir() {
return join(pluginRoot(), 'prompts');
}
/**
* 所有工作樹的集中處:`~/.tea-sdlc/worktrees`。
* 集中在一個地方,清理時只有一處要看。`TEA_SDLC_HOME` 只是測試與 CI 的覆寫出口,
* 正常使用不必設。
*/
function worktreesRoot() {
const home = process.env.TEA_SDLC_HOME?.trim() || join(homedir(), '.tea-sdlc');
return join(home, 'worktrees');
}
/**
* 由「哪顆工作包」純函式推導出「它的工作樹在哪」。
*
* 不查表、不讀狀態檔:任何流程(開工、處理留言、清理)都要算得出同一條路徑,
* 換一台機器或換一個 agent 也一樣,不存在就重建。
*
* 目錄名取雜湊而不是把分支名的斜線攤平成 `-`:攤平會讓 `feat/a-b/main` 與
* `feat/a/b/main` 撞成同一個目錄,而本專案的分支命名規則恰好讓這種形狀有機會出現。
* 可讀性的缺口由 `git worktree list` 補上——它本來就會把分支名印在路徑旁邊。
*
* 推導前先正規化:沒有它,同一棵工作樹會因為輸入多一個空白或大小寫不同而被推導成兩條路徑。
*
* @param {string} repo owner/name
* @param {string} branch 分支名,可含斜線
* @returns {string} ~/.tea-sdlc/worktrees/{sha256 前 12 碼}
*/
export function worktreePath(repo, branch) {
const key = `${normalizeRef(repo)}/${normalizeRef(branch)}`;
const hash = createHash('sha256').update(key).digest('hex').slice(0, 12);
return join(worktreesRoot(), hash);
}
/** 逐段修掉空白再轉小寫:`Plugins / Tea-SDLC` 與 `plugins/tea-sdlc` 是同一個東西。 */
function normalizeRef(value) {
return value
.split('/')
.map((segment) => segment.trim())
.join('/')
.toLowerCase();
}
// ── 套件 manifest ─────────────────────────────────────────────────
/**
@@ -99,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;
}
@@ -379,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 });
}
// ── 四層前置檢查 ───────────────────────────────────────────────────
/**
@@ -584,6 +651,54 @@ export async function countUnmergedComments(login, repo, index) {
return unmerged;
}
// ── 碼錶 ───────────────────────────────────────────────────────────
/**
* 目前跑在自己身上的碼錶。
*
* Gitea 只讓人讀自己的碼錶,看不到別人的——所以這份清單的語意永遠是「**我**的錶」,
* 它用來發現自己忘了停上一顆,不是用來判斷別人有沒有在做(那看 assignee)。
* @param {{base: string, token: string}} login
* @returns {Promise<object[]>}
*/
export async function listStopwatches(login) {
const path = '/user/stopwatches';
return expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? [];
}
/**
* 「你的錶正跑在別顆議題上」的擋路錯誤。領取與起錶都會撞到它,訊息只寫一份。
*
* 一定要明說停錶不會動到工作樹:使用者常以為停錶等於放棄那顆工作包,於是寧可不停,
* 而工時就記到別顆議題去了。碼錶只管時間,工作樹只管檔案,兩者互不相干。
* @param {object} watch listStopwatches 裡的一顆錶
* @param {string} 動作 擋在哪件事之前,例如「領取」「起錶」
*/
export function stopwatchElsewhere(watch, 動作) {
return new ScriptError(
'STOPWATCH_ON_OTHER_ISSUE',
`你的碼錶正跑在 ${watch.repo_owner_name}/${watch.repo_name} 的議題 ` +
`#${watch.issue_index} 上,${動作}前請先手動停錶,否則工時會記到那一顆去;` +
'停錶只停計時,不會動到任何既有的工作樹',
);
}
/**
* 這些碼錶裡,跑在指定議題上的那一顆。
* 比對要連 repo 一起看:不同 repo 的同號議題是兩件事。
* @param {object[]} watches listStopwatches 的結果
* @param {string} repo owner/name
* @param {number} index
* @returns {object|null}
*/
export function stopwatchOnIssue(watches, repo, index) {
return (
watches.find(
(watch) => `${watch.repo_owner_name}/${watch.repo_name}` === repo && watch.issue_index === index,
) ?? null
);
}
// ── 標籤 ───────────────────────────────────────────────────────────
/**
+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;
}
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* 起錶。
*
* 錶與領取鎖是兩件事:鎖用 assignee 加標籤(見 claim.js),錶只管工時。分開的理由是
* 時機不同——鎖要在開工之前就上好,錶則要等到**工作樹真的建好之後**才起。工作樹建立
* 失敗會中止整個領取,錶要是先起了,使用者就被計了一段什麼都沒做的時間,
* 而工時要準正是工時報表的立足點。
*
* 自己的錶跑在別顆議題上時擋下,不代勞停錶:那一段時間該記在哪顆議題上只有人知道,
* 腳本自作主張會把工時記錯地方。錶已經跑在本議題上則什麼都不做——重新起錶會把已經
* 累積的時間切成兩段,而中斷後重跑正是這支腳本最常見的處境。
*
* 用法:
* node scripts/timer.js --repo owner/name --index 40 [--host <網址>] [--dry-run]
*/
import {
expectOk,
fetchIssue,
giteaRequest,
listStopwatches,
main,
parseFlags,
parseIndex,
parseRepo,
preflight,
resolveLogin,
stopwatchElsewhere,
stopwatchOnIssue,
} from './lib.js';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['repo', 'index'],
optional: ['host'],
booleans: ['dry-run'],
});
const repo = parseRepo(flags.repo);
const index = parseIndex(flags.index);
const issuePath = `/repos/${repo}/issues/${index}`;
const dryRun = flags['dry-run'] === true;
const login = resolveLogin({ host: flags.host });
// 試跑照樣讀現況:手寫一份固定的清單會跟實作走鐘,也說不出「這顆已經在計時了」
if (!dryRun) await preflight(login, repo);
const issue = await fetchIssue(login, repo, index);
const watches = await listStopwatches(login);
const 已在計時 = stopwatchOnIssue(watches, repo, index) !== null;
if (!已在計時 && watches.length > 0) throw stopwatchElsewhere(watches[0], '起錶');
// 已經在跑就不重起:重新起錶會把已經累積的時間切成兩段
const planned = 已在計時 ? [] : [{ method: 'POST', path: `${issuePath}/stopwatch/start`, body: {} }];
if (dryRun) {
return { dryRun: true, repo, index, title: issue.title, requests: planned, 已在計時 };
}
for (const { method, path, body } of planned) {
expectOk(await giteaRequest(login, method, path, { body }), `${method} ${path}`);
}
return {
repo,
index: issue.number,
title: issue.title,
url: issue.html_url,
碼錶中: true,
已在計時,
};
});
+3 -8
View File
@@ -14,9 +14,8 @@
import {
UNMERGED_COMMENT_NOTE,
countUnmergedComments,
expectOk,
fetchIssue,
giteaRequest,
listStopwatches,
main,
pages,
parseFlags,
@@ -24,6 +23,7 @@ import {
parseRepo,
preflight,
resolveLogin,
stopwatchOnIssue,
} from './lib.js';
import {
checklistInSection,
@@ -118,10 +118,5 @@ async function fetchLinked(login, path, kind) {
* ——領取鎖看的是 assignee。
*/
async function hasRunningStopwatch(login, repo, index) {
const path = '/user/stopwatches';
const watches = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? [];
return watches.some(
(watch) => `${watch.repo_owner_name}/${watch.repo_name}` === repo && watch.issue_index === index,
);
return stopwatchOnIssue(await listStopwatches(login), repo, index) !== null;
}
+249 -99
View File
@@ -1,33 +1,52 @@
/**
* 備妥開工的分支。
* 備妥開工的工作樹。
*
* 兩件事各自要驗:
* 三件事各自要驗:
* 1. **分支命名**是純字串規則,表格驅動,成本最低、回歸價值最高。
* 規則錯了會一路帶到 PR 標題與 CI,事後改名很痛。
* 2. **不覆蓋他人進度**。來源分支在遠端已存在時要 pull 而不是重建,
* 目標分支已存在時要切過去而不是從來源蓋掉。這兩件事沒有真的遠端就驗不出來,
* 所以測試在臨時 repo 上跑真的 git。
* 2. **一律在獨立的工作樹上開工**。主工作區一個字都不該被動到——包含它未提交的變更,
* 以及它現在停在哪一支分支上。
* 3. **原子性與不覆蓋他人進度**。分支與工作樹是同一個動作,失敗時不留半成品;
* 起點一律取自遠端,目標分支已存在時接上去而不是蓋掉。
*
* git 不做替身:在臨時 repo 上跑真的 git,以本機裸 repo 充當遠端,不需網路。
* 工作樹則以 TEA_SDLC_HOME 改指到測試暫存,不落到開發者真正的家目錄。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { writeFileSync } from 'node:fs';
import { runScript, tmpRoot } from './helpers/run-script.js';
import { makeTempRepoWithRemote } from './helpers/temp-repo.js';
/** 開一個有遠端的臨時 repo,並登記在測試結束時清掉 */
const REPO = 'plugins/tea-sdlc';
/** 開一個有遠端的臨時 repo 與一個空的工作樹家,並登記在測試結束時清掉 */
function withRepo(t) {
const repo = makeTempRepoWithRemote();
t.after(() => repo.cleanup());
return repo;
mkdirSync(tmpRoot, { recursive: true });
const home = mkdtempSync(join(tmpRoot, 'home-'));
t.after(() => {
// 工作樹裡有 .git 檔指回主 repo,直接刪目錄即可;主 repo 隨後也會被刪掉
rmSync(home, { recursive: true, force: true });
});
return { ...repo, home };
}
const run = (repo, args) => runScript('branch-prep.js', ['--path', repo.dir, ...args]);
const run = (repo, args) =>
runScript('branch-prep.js', ['--repo', REPO, '--path', repo.dir, ...args], {
env: { TEA_SDLC_HOME: repo.home },
});
/** 目前 checkout 在哪一支 */
/** 主工作區目前停在哪一支 */
const currentBranch = (repo) => repo.git('rev-parse', '--abbrev-ref', 'HEAD');
/** 工作樹裡 checkout 出來的是哪一支 */
const branchIn = (dir) =>
execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim();
// ── 分支命名規則 ───────────────────────────────────────────────────
const NAMING = [
@@ -66,13 +85,14 @@ const NAMING = [
for (const { name, source, args, expected } of NAMING) {
test(`命名:${name}`, async (t) => {
const repo = withRepo(t);
if (source !== 'master') repo.git('checkout', '-q', '-B', source);
// 來源分支一律取自遠端,所以先讓遠端有這一支
if (source !== 'master') repo.pushFromElsewhere(source, `${expected.replace(/\//g, '-')}.txt`, '來源\n');
const { code, json } = await run(repo, ['--source', source, ...args]);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.branch, expected);
assert.equal(currentBranch(repo), expected);
assert.equal(branchIn(json.data.worktree), expected, '工作樹裡 checkout 出來的要是新分支');
});
}
@@ -128,7 +148,7 @@ test('從開發分支長出卻沒給 --type 時,指名缺的是哪一個', asy
test('從功能分支長出時給 --type 會被擋,避免子分支跑到別棵樹下', async (t) => {
const repo = withRepo(t);
repo.git('checkout', '-q', '-B', 'feat/wp-extract-contract/main');
repo.pushFromElsewhere('feat/wp-extract-contract/main', 'src.txt', '來源\n');
const { json } = await run(repo, [
'--source', 'feat/wp-extract-contract/main', '--type', 'fix', '--slug', 'crlf',
@@ -138,41 +158,72 @@ test('從功能分支長出時給 --type 會被擋,避免子分支跑到別棵
assert.match(json.error.message, /feat/);
});
// ── 來源分支:pull 而不是重建 ─────────────────────────────────────
// ── 一律建立工作樹 ─────────────────────────────────────────────────
test('來源分支在遠端已存在時執行 pull,帶進別人的進度', async (t) => {
test('分支與工作樹一起建立,主工作區完全不被動到', async (t) => {
const repo = withRepo(t);
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
assert.equal(code, 0, json.error?.message);
assert.equal(existsSync(json.data.worktree), true, '工作樹要真的在磁碟上');
assert.equal(branchIn(json.data.worktree), 'feat/mine/main');
assert.equal(currentBranch(repo), 'master', '主工作區不該被切走:agent 會在那裡讀到不屬於它的程式碼');
});
test('主工作區有未提交的變更時照樣開得了工,那正是工作樹要解決的事', async (t) => {
const repo = withRepo(t);
writeFileSync(join(repo.dir, 'README.md'), '改到一半的東西\n');
writeFileSync(join(repo.dir, 'stray.txt'), '不相干的檔案\n');
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
assert.equal(code, 0, json.error?.message);
assert.equal(
repo.git('status', '--porcelain').includes('stray.txt'),
true,
'未提交的變更要原封不動留在主工作區,不被帶到新分支上',
);
assert.equal(existsSync(join(json.data.worktree, 'stray.txt')), false, '也不該跟到工作樹裡');
});
test('工作樹路徑在集中的家底下,不長在目標專案裡也不長在它的兄弟目錄', async (t) => {
const repo = withRepo(t);
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
assert.equal(json.data.worktree.startsWith(join(repo.home, 'worktrees')), true);
assert.equal(repo.git('status', '--porcelain'), '', '工作樹不該出現在目標專案的 git status 裡');
});
// ── 起點一律取自遠端 ───────────────────────────────────────────────
test('本機落後時,工作樹仍從遠端的最新狀態長出', async (t) => {
const repo = withRepo(t);
repo.pushFromElsewhere('master', 'theirs.txt', '別人的進度\n');
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
assert.equal(code, 0, json.error?.message);
assert.ok(existsSync(join(repo.dir, 'theirs.txt')), '別人推上去的檔案要被帶進來');
assert.equal(json.data.來源.動作, 'pull');
assert.equal(existsSync(join(json.data.worktree, 'theirs.txt')), true, '起點要是遠端的最新狀態');
assert.equal(existsSync(join(repo.dir, 'theirs.txt')), false, '主工作區不必被順便更新');
});
test('來源分支只在本地時照樣可用,不會因為遠端沒有就報錯', async (t) => {
test('遠端沒有來源分支時明確中止,不退回本機同名分支', async (t) => {
const repo = withRepo(t);
repo.git('checkout', '-q', '-B', 'feat/local-only/main');
repo.git('checkout', '-q', 'master');
repo.git('branch', 'feat/local-only/main');
const { code, json } = await run(repo, [
'--source', 'feat/local-only/main', '--slug', 'sub-feature',
]);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.來源.動作, '用本地既有');
});
test('來源分支本地與遠端都沒有時,回可區分的錯誤碼', async (t) => {
const repo = withRepo(t);
const { code, json } = await run(repo, [
'--source', 'feat/不存在/main', '--slug', 'whatever',
]);
const { code, json } = await run(repo, ['--source', 'feat/local-only/main', '--slug', 'sub-feature']);
assert.equal(code, 1);
assert.equal(json.error.code, 'SOURCE_NOT_FOUND');
assert.match(json.error.message, /推/, '要指出下一步是把來源分支推上去');
assert.match(json.error.message, /來源/, '或改指定一個已存在的來源分支');
assert.equal(
repo.git('branch', '--list', 'feat/local-only/sub-feature'),
'',
'擋下來就不該已經建好分支',
);
});
test('遠端分支的比對是全名,不是尾段', async (t) => {
@@ -188,95 +239,150 @@ test('遠端分支的比對是全名,不是尾段', async (t) => {
assert.equal(json.error.code, 'SOURCE_NOT_FOUND', '遠端沒有 main 這一支,不該被 feat/x/main 冒名頂替');
});
// ── 開工前的工作區必須乾淨 ─────────────────────────────────────────
test('工作區有未提交的改動時擋下,不把它們帶進新分支', async (t) => {
test('不為新分支設定 upstream:此刻遠端還沒有這一支,設了會誤指到來源分支', async (t) => {
const repo = withRepo(t);
writeFileSync(join(repo.dir, 'README.md'), '改到一半的東西\n');
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
assert.equal(code, 1);
assert.equal(json.error.code, 'DIRTY_WORKTREE');
assert.equal(currentBranch(repo), 'master', '擋下來就不該已經切過分支');
assert.equal(repo.git('branch', '--list', 'feat/mine/main'), '', '也不該已經建好分支');
});
test('未追蹤的檔案同樣算不乾淨:它會跟著被帶到新分支上', async (t) => {
const repo = withRepo(t);
writeFileSync(join(repo.dir, 'stray.txt'), '不相干的檔案\n');
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
assert.equal(json.error.code, 'DIRTY_WORKTREE');
assert.match(json.error.message, /stray\.txt/, '要指名是哪些檔案擋住了');
assert.equal(
repo.git('for-each-ref', '--format=%(upstream)', 'refs/heads/feat/mine/main'),
'',
'upstream 指到來源分支的話,之後 git pull 會把來源分支的提交拉進來',
);
assert.equal(json.data.branch, 'feat/mine/main');
});
test('工作區不乾淨時,--dry-run 也要照樣說出來', async (t) => {
// 試跑印得出漂亮的計畫、實跑卻中途炸掉,是最難查的那種落差
// ── 原子性:失敗不留半成品 ─────────────────────────────────────────
test('工作樹建不起來時,不留下那一支已經建好的分支', async (t) => {
// git worktree add 失敗時仍會把分支留下來,那是最難查的半成品:
// 下一次重跑會走到「目標分支已存在」那條路,起點從此不是遠端的來源分支
const repo = withRepo(t);
writeFileSync(join(repo.dir, 'stray.txt'), '不相干的檔案\n');
const blocked = join(repo.home, 'blocker');
writeFileSync(blocked, '這是一個檔案,不是目錄\n');
const { json } = await run(repo, [
'--source', 'master', '--type', 'feat', '--slug', 'mine', '--dry-run',
]);
assert.equal(json.error.code, 'DIRTY_WORKTREE');
});
// ── 來源分支與遠端分歧 ─────────────────────────────────────────────
test('本地來源分支與遠端分歧時,回可區分的錯誤碼而不是 git 的原始訊息', async (t) => {
const repo = withRepo(t);
// 本地有一顆沒推的 commit,遠端也往前走了一顆
writeFileSync(join(repo.dir, 'mine.txt'), '我的\n');
repo.git('add', '-A');
repo.git('commit', '-qm', '本地未推的 commit');
repo.pushFromElsewhere('master', 'theirs.txt', '別人的\n');
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
const { code, json } = await runScript(
'branch-prep.js',
['--repo', REPO, '--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', 'mine'],
{ env: { TEA_SDLC_HOME: blocked } },
);
assert.equal(code, 1);
assert.equal(json.error.code, 'SOURCE_DIVERGED');
assert.match(json.error.message, /master/);
assert.equal(currentBranch(repo), 'master', '不該留在半途的狀態');
assert.equal(json.error.code, 'GIT_FAILED');
assert.equal(repo.git('branch', '--list', 'feat/mine/main'), '', '分支不該留下來');
assert.equal(
repo.git('worktree', 'list', '--porcelain').includes('feat/mine/main'),
false,
'也不該留下工作樹的中繼資料',
);
});
// ── 目標分支:已存在就切過去,不覆蓋 ───────────────────────────────
// ── 目標分支已存在:接上去,不覆蓋 ─────────────────────────────────
test('目標分支已在遠端時切過去,保留上面已有的進度', async (t) => {
test('目標分支已在遠端時接上去,保留上面已有的進度', async (t) => {
const repo = withRepo(t);
repo.pushFromElsewhere('feat/mine/main', 'progress.txt', '已經做了一半\n');
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
assert.equal(code, 0, json.error?.message);
assert.equal(currentBranch(repo), 'feat/mine/main');
assert.ok(
existsSync(join(repo.dir, 'progress.txt')),
assert.equal(json.data.分支.動作, '接上遠端既有');
assert.equal(
existsSync(join(json.data.worktree, 'progress.txt')),
true,
'遠端已有的分支要接上去,不是從來源重建一個空的蓋掉',
);
assert.equal(json.data.分支.動作, '接上遠端既有');
});
test('目標分支只在本地時切過去,不重建', async (t) => {
test('目標分支只在本地時接上去,不重建', async (t) => {
const repo = withRepo(t);
repo.git('checkout', '-q', '-b', 'feat/mine/main');
repo.git('checkout', '-q', 'master');
repo.git('branch', 'feat/mine/main');
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
assert.equal(json.data.分支.動作, '切換到本地既有');
assert.equal(currentBranch(repo), 'feat/mine/main');
assert.equal(json.data.分支.動作, '接上本地既有');
assert.equal(branchIn(json.data.worktree), 'feat/mine/main');
});
test('目標分支不存在時從來源建立', async (t) => {
test('目標分支不存在時從遠端的來源分支建立', async (t) => {
const repo = withRepo(t);
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'brand-new']);
assert.equal(json.data.分支.動作, '從來源建立');
assert.equal(currentBranch(repo), 'feat/brand-new/main');
});
// ── 冪等:工作樹已經在了 ───────────────────────────────────────────
test('工作樹已經在了就沿用,不動裡面還沒提交的東西', async (t) => {
const repo = withRepo(t);
const first = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
writeFileSync(join(first.json.data.worktree, 'wip.txt'), '做到一半\n');
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.worktree, first.json.data.worktree, '推導出來的是同一條路徑');
assert.equal(json.data.分支.動作, '沿用既有工作樹');
assert.equal(existsSync(join(json.data.worktree, 'wip.txt')), true, '重跑不該把做到一半的東西刷掉');
});
test('推導出來的路徑被別的東西佔住時明確中止,不硬蓋過去', async (t) => {
const repo = withRepo(t);
const { json: first } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
// 造出「別的 clone 留下的目錄」:本 repo 的 git 已經不認得它了,但路徑還在
repo.git('worktree', 'remove', '--force', first.data.worktree);
mkdirSync(first.data.worktree, { recursive: true });
writeFileSync(join(first.data.worktree, '別人的東西.txt'), 'x\n');
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
assert.equal(code, 1);
assert.equal(json.error.code, 'WORKTREE_PATH_TAKEN');
assert.match(json.error.message, new RegExp(first.data.worktree), '要指名是哪一條路徑被佔住');
assert.equal(existsSync(join(first.data.worktree, '別人的東西.txt')), true, '不得動到裡面的東西');
});
// ── 乾淨的工作樹 ───────────────────────────────────────────────────
test('建好後說明這是一棵乾淨的工作樹,並依專案檔給出安裝指令', async (t) => {
const repo = withRepo(t);
repo.pushFromElsewhere('master', 'package.json', '{"name":"demo"}\n');
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
assert.deepEqual(json.data.提示.安裝指令, ['npm install']);
assert.match(json.data.提示.訊息, /乾淨/);
});
test('偵測不到專案檔時不亂猜指令,只說明它是乾淨的', async (t) => {
const repo = withRepo(t);
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
assert.deepEqual(json.data.提示.安裝指令, []);
assert.match(json.data.提示.訊息, /乾淨/);
});
test('多種語言的專案檔都偵測得到,各給各的指令', async (t) => {
const repo = withRepo(t);
repo.pushFromElsewhere('master', 'composer.json', '{}\n');
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
assert.deepEqual(json.data.提示.安裝指令, ['composer install']);
});
test('不複製也不連結依賴、建置產物與機密檔案', async (t) => {
const repo = withRepo(t);
mkdirSync(join(repo.dir, 'node_modules', 'left-pad'), { recursive: true });
writeFileSync(join(repo.dir, 'node_modules', 'left-pad', 'index.js'), '\n');
writeFileSync(join(repo.dir, '.env'), 'TOKEN=秘密\n');
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
assert.equal(existsSync(join(json.data.worktree, 'node_modules')), false, 'symlink 會把隔離接回去');
assert.equal(existsSync(join(json.data.worktree, '.env')), false, '機密一律由使用者自己放');
});
// ── 不留本機狀態檔 ─────────────────────────────────────────────────
@@ -286,12 +392,11 @@ test('跑完不在目標專案裡留下任何狀態檔', async (t) => {
await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'no-state']);
const status = repo.git('status', '--porcelain');
assert.equal(status, '', '工作區要是乾淨的:進度只從 Gitea 推導,不寫本機狀態檔');
assert.equal(repo.git('status', '--porcelain'), '', '進度只從 Gitea 與 git 本身推導,不寫本機狀態檔');
});
test('git 自己的進度訊息不漏到 stderr', async (t) => {
// checkout 與 fetch 的訊息 git 一律寫在 stderr。腳本的輸出契約是「stdout 一行 JSON、
// fetch 與 worktree add 的訊息 git 一律寫在 stderr。腳本的輸出契約是「stdout 一行 JSON、
// stderr 乾淨」,漏出去的話呼叫端就得去分辨哪幾行是雜訊。
const repo = withRepo(t);
@@ -300,22 +405,43 @@ test('git 自己的進度訊息不漏到 stderr', async (t) => {
assert.equal(stderr, '');
});
// ── 路徑 ───────────────────────────────────────────────────────────
// ── 路徑與 flag ───────────────────────────────────────────────────
test('--path 指向的不是 git repo 時,回可區分的錯誤碼', async (t) => {
const { json } = await runScript('branch-prep.js', [
'--path', tmpRoot, '--source', 'master', '--type', 'feat', '--slug', 'whatever',
'--repo', REPO, '--path', tmpRoot, '--source', 'master', '--type', 'feat', '--slug', 'whatever',
]);
assert.equal(json.error.code, 'NOT_A_GIT_REPO');
});
test('缺 --repo 時擋下:沒有它推導不出工作樹在哪', async (t) => {
const repo = withRepo(t);
const { json } = await runScript('branch-prep.js', [
'--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', 'mine',
], { env: { TEA_SDLC_HOME: repo.home } });
assert.equal(json.error.code, 'MISSING_FLAG');
assert.match(json.error.message, /--repo/);
});
test('目標專案沒有 origin 時擋下:起點一律取自遠端', async (t) => {
const repo = withRepo(t);
repo.git('remote', 'remove', 'origin');
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
assert.equal(json.error.code, 'NO_ORIGIN');
});
test('回報實際動到的是哪一個目錄,讓人確認沒搞錯專案', async (t) => {
const repo = withRepo(t);
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
assert.equal(json.data.path, repo.dir);
assert.equal(json.data.repo, REPO);
});
// ── --dry-run ─────────────────────────────────────────────────────
@@ -331,23 +457,47 @@ test('--dry-run 印出將執行的 git 指令,且完全不動 repo', async (t)
assert.equal(code, 0);
assert.equal(json.data.dryRun, true);
assert.equal(json.data.branch, 'feat/mine/main');
assert.ok(json.data.commands.length > 0);
assert.ok(
json.data.commands.every((c) => c.startsWith('git ')),
'預覽的是 git 指令本身,不是自創的描述',
);
assert.equal(currentBranch(repo), 'master', '試跑不該切分支');
assert.equal(repo.git('rev-parse', 'HEAD'), before);
assert.equal(repo.git('branch', '--list', 'feat/mine/main'), '', '試跑不該建分支');
assert.equal(existsSync(json.data.worktree), false, '試跑不該建工作樹');
});
test('--dry-run 連分支命名都先算出來,看得到才叫預覽', async (t) => {
test('--dry-run 把 fetch 與建立工作樹兩步都印出來,順序不顛倒', async (t) => {
const repo = withRepo(t);
repo.git('checkout', '-q', '-B', 'feat/wp-extract-contract/main');
const { json } = await run(repo, [
'--source', 'master', '--type', 'feat', '--slug', 'mine', '--dry-run',
]);
assert.deepEqual(json.data.commands, [
'git fetch origin',
`git worktree add --no-track -b feat/mine/main ${json.data.worktree} origin/master`,
], '建分支與建工作樹是同一個指令,不拆成兩步');
});
test('--dry-run 連工作樹路徑都先算出來,看得到才叫預覽', async (t) => {
const repo = withRepo(t);
repo.pushFromElsewhere('feat/wp-extract-contract/main', 'src.txt', '來源\n');
const { json } = await run(repo, [
'--source', 'feat/wp-extract-contract/main', '--slug', 'crlf-fix', '--dry-run',
]);
assert.equal(json.data.branch, 'feat/wp-extract-contract/crlf-fix');
assert.match(json.data.worktree, /worktrees\/[0-9a-f]{12}$/);
});
test('遠端沒有來源分支時,--dry-run 也要照樣說出來', async (t) => {
// 試跑印得出漂亮的計畫、實跑卻中途炸掉,是最難查的那種落差
const repo = withRepo(t);
const { json } = await run(repo, [
'--source', 'feat/沒有這支/main', '--slug', 'mine', '--dry-run',
]);
assert.equal(json.error.code, 'SOURCE_NOT_FOUND');
});
+19 -11
View File
@@ -1,6 +1,10 @@
/**
* 領取工作包的鎖。
*
* 錶不在這一支起——它等工作樹建好之後才由 timer.js 起動,所以這裡連帶要驗
* 「一發起錶請求都沒有」:領取失敗或工作樹建不起來時,使用者不該被計一段
* 什麼都沒做的時間。
*
* 這一支的價值全在「什麼時候擋下來」:放行的路徑只有一條,擋的理由有四種,
* 而擋錯的代價是兩個人做同一件事、或是工時記到別顆議題上。所以決策表的四種狀態
* 各有測試,而且每一種都要驗「一個字都沒寫進 Gitea」——擋下來卻已經改了一半,
@@ -68,7 +72,7 @@ const writes = (stub) =>
// ── 決策表:無鎖 ───────────────────────────────────────────────────
test('沒有鎖時放行:設 assignee、貼進行中、起錶', async (t) => {
test('沒有鎖時放行:設 assignee、貼進行中', async (t) => {
const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent', '進行中'] });
const { code, json } = await run([], stub);
@@ -76,11 +80,11 @@ test('沒有鎖時放行:設 assignee、貼進行中、起錶', async (t) => {
assert.equal(code, 0);
assert.equal(json.data.assignee, ME);
assert.deepEqual(json.data.labels, ['進行中']);
assert.equal(json.data.碼錶中, true);
assert.equal(json.data.碼錶中, false, '鎖上好了,錶還沒起');
assert.equal(json.data.已認領過, false);
});
test('放行時三個寫入請求都發出,且順序為先上鎖再起錶', async (t) => {
test('放行時只寫入鎖的那兩件事,一發起錶請求都沒有', async (t) => {
const stub = await withStub(t, {}, { repoLabels: ['進行中'] });
await run([], stub);
@@ -90,9 +94,8 @@ test('放行時三個寫入請求都發出,且順序為先上鎖再起錶', as
[
`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`,
`POST /api/v1/repos/${REPO}/issues/${INDEX}/labels`,
`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/start`,
],
'錶要最後才起:前面任一步失敗時,不該留下一顆還在跑的碼錶',
'錶等工作樹建好之後才由 timer.js 起動:建不起來就中止,不該已經計了時間',
);
});
@@ -142,6 +145,7 @@ test('自己碼錶跑在本議題時擋下,要求先手動停錶', async (t) =
assert.equal(code, 1);
assert.equal(json.error.code, 'STOPWATCH_ON_THIS_ISSUE');
assert.match(json.error.message, /停/, '要說清楚下一步是手動停錶');
assert.match(json.error.message, /工作樹/, '要明說停錶不會動到既有的工作樹');
assert.deepEqual(writes(stub), []);
});
@@ -156,6 +160,11 @@ test('自己碼錶跑在別的議題時擋下,並指出是哪一顆', async (t
assert.equal(code, 1);
assert.equal(json.error.code, 'STOPWATCH_ON_OTHER_ISSUE');
assert.match(json.error.message, /#7/, '忘了停掉的是哪一顆,要指名');
assert.match(
json.error.message,
/停錶只停計時,不會動到任何既有的工作樹/,
'以為停錶等於放棄那顆工作包的人會寧可不停,工時就記到別顆去了',
);
assert.deepEqual(writes(stub), []);
});
@@ -180,7 +189,7 @@ test('自己已認領但沒有錶時放行,並如實說這顆本來就是自
assert.equal(code, 0);
assert.equal(json.data.已認領過, true);
assert.equal(json.data.碼錶中, true, '錶還是要起,中斷重跑就是為了接上這件事');
assert.equal(json.data.碼錶中, false, '重跑時鎖照樣補齊,錶則仍舊留到工作樹建好之後');
});
test('進行中標籤已經在議題上時不重複貼', async (t) => {
@@ -251,7 +260,6 @@ test('--dry-run 印出將發出的寫入,但一個字都不寫進去', async (
[
`PATCH /repos/${REPO}/issues/${INDEX}`,
`POST /repos/${REPO}/issues/${INDEX}/labels`,
`POST /repos/${REPO}/issues/${INDEX}/stopwatch/start`,
],
);
assert.deepEqual(writes(stub), [], '預覽不得真的寫入');
@@ -270,7 +278,7 @@ test('--dry-run 會先讀現況:預覽出來的是這一顆實際的處境', a
assert.ok(reads.includes(`/api/v1/repos/${REPO}/labels`));
});
test('--dry-run 略過已經做好的部分,不謊報將發出的請求', async (t) => {
test('--dry-run 略過已經做好的部分:鎖都在了就什麼都不必寫', async (t) => {
const stub = await withStub(t, {}, {
assignees: [ME],
labels: ['進行中'],
@@ -280,9 +288,9 @@ test('--dry-run 略過已經做好的部分,不謊報將發出的請求', asyn
const { json } = await run(['--dry-run'], stub);
assert.deepEqual(
json.data.requests.map((r) => `${r.method} ${r.path}`),
[`POST /repos/${REPO}/issues/${INDEX}/stopwatch/start`],
'assignee 與標籤都已經到位,只差起錶',
json.data.requests,
[],
'assignee 與標籤都已經到位,中斷重跑就是走到這裡;手寫一份固定的清單會謊報',
);
});
+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) => {
+4 -5
View File
@@ -1,10 +1,9 @@
/**
* 這一支是唯一直接 import lib 的測試,其餘一律走子行程的 CLI 邊界。
* 直接 import lib 的兩支測試之一(另一支是 worktree-path),其餘一律走子行程的 CLI 邊界。
*
* 理由:冪等查重與 git 執行點是 lib 對「其他腳本」公開的契約,但本工作包只交付
* labels-list(讀取型、不碰 git、不需查重),CLI 邊界上還沒有消費者。等 #4 的
* issue-create 與 #10 的 branch-prep 落地後,它們的 CLI 測試才是這兩件事的主場,
* 屆時這支可以縮小或移除。在那之前直接測 export,好過讓契約完全沒有測試。
* 理由:冪等查重與 git 執行點是 lib 對「其他腳本」公開的契約,而 issue-create 與
* branch-prep 落地之後,它們的 CLI 測試才是這兩件事的主場——這一支已經是備位的,
* 留著是因為直接測 export 仍比讓契約完全沒有測試好。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
+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);
+234 -12
View File
@@ -9,18 +9,46 @@ 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');
});
test('第一段指名三支腳本,順序為先讀再領再備分支', () => {
const order = ['wp-extract.js', 'claim.js', 'branch-prep.js'];
test('第一段指名四支腳本,順序為先讀再領、備妥工作樹、最後起錶', () => {
const order = ['wp-extract.js', 'claim.js', 'branch-prep.js', 'timer.js'];
const positions = order.map((name) => phase1.indexOf(name));
assert.equal(positions.every((p) => p >= 0), true, '三支腳本都要被指名');
assert.deepEqual([...positions].sort((a, b) => a - b), positions, '領取之前要先讀得懂這顆在做什麼');
assert.equal(positions.every((p) => p >= 0), true, '四支腳本都要被指名');
assert.deepEqual(
[...positions].sort((a, b) => a - b),
positions,
'領取之前要先讀得懂這顆在做什麼;錶則要等工作樹建好之後才起',
);
});
test('錶等工作樹建好之後才起,並說明為什麼', () => {
assert.match(phase1, /錶不在這一步起/, '領取那一步要明講錶還沒起');
assert.match(phase1, /什麼都沒做的時間/, '要說明為什麼後移:失敗的領取不該留下憑空的工時');
});
test('工作樹一律建立,建不起來就中止而不是退回原地切分支', () => {
assert.match(phase1, /一律建立,沒有例外/);
assert.match(phase1, /不要改成在原地切分支/);
assert.match(phase1, /以為自己在隔離環境裡/, '要說明靜默降級的後果');
});
test('實作要在工作樹裡做,路徑從輸出取得', () => {
assert.match(phase1, /worktree/, '工作樹路徑印在哪個欄位要講');
assert.match(phase1, /都在那棵工作樹裡做/);
});
test('工作樹是乾淨的,且機密檔案不會被複製過去', () => {
assert.match(phase1, /沒有安裝依賴/);
assert.match(phase1, /安裝指令/);
assert.match(phase1, /\.env/);
});
test('未處理留言不是 0 時要先停下來提示整併', () => {
@@ -48,9 +76,16 @@ test('工作包跨多個 repo 時怎麼開分支,有交代', () => {
assert.match(phase1, /有多顆時逐一確認/);
});
test('工作區不乾淨時的處置寫明了,且不替使用者決定', () => {
assert.match(phase1, /DIRTY_WORKTREE/);
assert.match(phase1, /不要自己選/);
test('兩種擋下來的情境各自寫明處置,且都不替使用者決定', () => {
assert.match(phase1, /SOURCE_NOT_FOUND/);
assert.match(phase1, /不要自己換一個/, '來源分支是使用者的決定,不要自己改指定別支');
assert.match(phase1, /WORKTREE_PATH_TAKEN/);
assert.match(phase1, /不要自己刪/, '路徑上的東西可能還沒保存,不該由 agent 決定刪掉');
});
test('被碼錶擋下時要說明停錶不會動到工作樹', () => {
assert.match(phase1, /停錶不會動到既有的工作樹/);
assert.match(phase1, /以為停錶等於放棄那顆工作包/, '要說明不講清楚的後果');
});
test('碼錶只由使用者自己停,並說明為什麼不代勞', () => {
@@ -77,13 +112,13 @@ test('--type 什麼時候要給、什麼時候不能給,寫清楚了', () => {
});
test('兩處「不覆蓋他人進度」的保證都有寫出來', () => {
assert.match(phase1, /pull 而不是重建/);
assert.match(phase1, /起點一律是遠端的來源分支/, '根本不碰本機分支,就沒有覆蓋的可能');
assert.match(phase1, /接上去而不是蓋掉/);
});
test('三支腳本的寫入都要求先試跑', () => {
test('三支寫入型腳本都要求先試跑', () => {
const dryRuns = phase1.match(/--dry-run/g) ?? [];
assert.ok(dryRuns.length >= 2, `兩支寫入型腳本各要先試跑,只找到 ${dryRuns.length} 處`);
assert.ok(dryRuns.length >= 3, `三支寫入型腳本各要先試跑,只找到 ${dryRuns.length} 處`);
});
test('邊界把第一段不做的事分開列,且明講不寫本機狀態檔', () => {
@@ -92,6 +127,193 @@ test('邊界把第一段不做的事分開列,且明講不寫本機狀態檔',
assert.match(boundary, /不勾待辦/);
assert.match(boundary, /不開 PR/);
assert.match(boundary, /不自行建立標籤/);
assert.match(boundary, /不在主工作區動手/);
assert.match(boundary, /不退回原地切分支/);
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 的類型與描述/);
});
+149
View File
@@ -0,0 +1,149 @@
/**
* 起錶。
*
* 錶是工時報表的唯一來源,所以這一支的價值全在「什麼時候不該起」:工作樹還沒建好
* 不該起(那由流程的順序保證),自己的錶已經跑在別顆議題上更不該起——那會把兩顆
* 工作包的時間攪在一起。停錶一律由使用者自己來,這裡不提供。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { runScript } from './helpers/run-script.js';
import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js';
const REPO = 'plugins/tea-sdlc';
const INDEX = 40;
/** 議題上跑著的碼錶長什麼樣 */
const stopwatchOn = (index, repo = REPO) => ({
issue_index: index,
repo_owner_name: repo.split('/')[0],
repo_name: repo.split('/')[1],
});
function routes(overrides = {}, { stopwatches = [] } = {}) {
return healthyRoutes(REPO, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
status: 200,
body: {
number: INDEX,
title: '以 worktree 建立工作包分支並備妥隔離環境',
html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/${INDEX}`,
},
},
'GET /api/v1/user/stopwatches': { status: 200, body: stopwatches },
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/start`]: { status: 201, body: {} },
...overrides,
});
}
const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options));
const run = (args, stub) =>
runScript('timer.js', ['--repo', REPO, '--index', String(INDEX), ...args], { env: envFor(stub) });
/** 會改動 Gitea 的請求;前置檢查打在 issues/0 的探針不算(見 lib 的 checkIssueWrite) */
const writes = (stub) =>
stub.requests.filter((r) => r.method !== 'GET').filter((r) => !r.path.endsWith('/issues/0'));
test('沒有錶在跑時起錶,並回報起在哪一顆上', async (t) => {
const stub = await withStub(t);
const { code, json } = await run([], stub);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.碼錶中, true);
assert.equal(json.data.index, INDEX);
assert.deepEqual(
writes(stub).map((r) => `${r.method} ${r.path}`),
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/start`],
);
});
test('錶已經跑在這顆議題上時什麼都不做,重跑不會把計時打斷', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX)] });
const { code, json } = await run([], stub);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.碼錶中, true);
assert.equal(json.data.已在計時, true, '要如實說這顆本來就在計時,不要假裝是這次起的');
assert.deepEqual(writes(stub), [], '重新起錶會把已經累積的時間切成兩段');
});
test('錶跑在別顆議題上時擋下,並指出是哪一顆', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(7)] });
const { code, json } = await run([], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'STOPWATCH_ON_OTHER_ISSUE');
assert.match(json.error.message, /#7/, '忘了停掉的是哪一顆,要指名');
assert.deepEqual(writes(stub), [], '擋下來就不該寫進任何東西');
});
test('別的 repo 上的同號碼錶也算自己有錶在跑', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX, 'plugins/別的專案')] });
const { json } = await run([], stub);
assert.equal(json.error.code, 'STOPWATCH_ON_OTHER_ISSUE');
assert.match(json.error.message, /別的專案/);
});
test('不代替使用者停錶:訊息要說清楚下一步是他自己去停', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(7)] });
const { json } = await run([], stub);
assert.match(json.error.message, /停/);
assert.match(
json.error.message,
/不會動到任何既有的工作樹/,
'碼錶只管時間、工作樹只管檔案;不講清楚,使用者會以為停錶等於放棄那顆工作包',
);
});
test('議題不存在時回可區分的錯誤碼', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 404, body: { message: 'not found' } },
});
const { json } = await run([], stub);
assert.equal(json.error.code, 'ISSUE_NOT_FOUND');
assert.deepEqual(writes(stub), []);
});
test('--index 不是正整數時擋在打 Gitea 之前', async (t) => {
const stub = await withStub(t);
const { json } = await runScript('timer.js', ['--repo', REPO, '--index', '0'], {
env: envFor(stub),
});
assert.equal(json.error.code, 'BAD_INDEX');
assert.equal(stub.requests.length, 0);
});
test('--dry-run 印出將發出的寫入,但一個字都不寫進去', async (t) => {
const stub = await withStub(t);
const { code, json } = await run(['--dry-run'], stub);
assert.equal(code, 0);
assert.equal(json.data.dryRun, true);
assert.deepEqual(
json.data.requests.map((r) => `${r.method} ${r.path}`),
[`POST /repos/${REPO}/issues/${INDEX}/stopwatch/start`],
);
assert.deepEqual(writes(stub), [], '預覽不得真的寫入');
});
test('--dry-run 會先讀現況:已經在計時時預覽出來就是什麼都不做', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX)] });
const { json } = await run(['--dry-run'], stub);
assert.deepEqual(json.data.requests, [], '手寫一份固定的清單會跟實作走鐘');
assert.equal(json.data.已在計時, true);
});
+131
View File
@@ -0,0 +1,131 @@
/**
* 工作樹路徑的推導。
*
* 這是一個純函式,而且是整套工作樹機制的地基:任何流程都要能從「這是哪顆工作包」
* 算出「它的工作樹在哪」,不查表、不讀狀態檔,換一台機器算出來也要一樣。
* 算錯的代價是找不到既有的工作樹而重建一棵,或兩顆工作包撞進同一個目錄。
*
* 這一支直接 import lib,是本專案第二個這麼做的測試:大小寫與空白的變體沒辦法
* 穿過 CLI 的 kebab 驗證送進去,而表格驅動正是這種純規則最划算的驗法。
* 最後一則測試把它與 CLI 實際印出的路徑釘在一起,避免兩邊各自為政。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { worktreePath } from '../scripts/lib.js';
import { runScript, tmpRoot } from './helpers/run-script.js';
import { makeTempRepoWithRemote } from './helpers/temp-repo.js';
/** 推導只看輸入,不看環境;家目錄則由 TEA_SDLC_HOME 決定,測試固定一個值好比對 */
const HOME = '/home/tester/.tea-sdlc';
const derive = (repo, branch) => {
process.env.TEA_SDLC_HOME = HOME;
try {
return worktreePath(repo, branch);
} finally {
delete process.env.TEA_SDLC_HOME;
}
};
test('路徑長在固定的家底下,目錄名是 12 碼十六進位', () => {
const path = derive('plugins/tea-sdlc', 'feat/worktree-branch-prep/main');
assert.match(path, new RegExp(`^${HOME}/worktrees/[0-9a-f]{12}$`));
});
// ── 同一顆工作包只能推導出一條路徑 ─────────────────────────────────
const SAME = [
{
name: '大小寫不同',
a: ['plugins/tea-sdlc', 'feat/mine/main'],
b: ['Plugins/Tea-SDLC', 'Feat/Mine/Main'],
},
{
name: '前後有空白',
a: ['plugins/tea-sdlc', 'feat/mine/main'],
b: [' plugins/tea-sdlc ', ' feat/mine/main '],
},
{
name: '斜線兩側有空白',
a: ['plugins/tea-sdlc', 'feat/mine/main'],
b: ['plugins / tea-sdlc', 'feat / mine / main'],
},
];
for (const { name, a, b } of SAME) {
test(`同一顆工作包:${name}的變體推導出同一條路徑`, () => {
assert.equal(derive(...a), derive(...b), '正規化沒做,同一棵工作樹會被推導成兩個目錄');
});
}
// ── 不同的工作包不能撞在一起 ───────────────────────────────────────
const DIFFERENT = [
{
name: '分支名的斜線位置不同:feat/a-b/main 與 feat/a/b/main',
a: ['plugins/tea-sdlc', 'feat/a-b/main'],
b: ['plugins/tea-sdlc', 'feat/a/b/main'],
},
{
name: '同名分支在不同的 repo 上',
a: ['plugins/tea-sdlc', 'feat/mine/main'],
b: ['plugins/別的專案', 'feat/mine/main'],
},
{
name: '同名 repo 在不同的 owner 底下',
a: ['plugins/tea-sdlc', 'feat/mine/main'],
b: ['someone/tea-sdlc', 'feat/mine/main'],
},
{
name: '同一個 repo 的不同分支',
a: ['plugins/tea-sdlc', 'feat/mine/main'],
b: ['plugins/tea-sdlc', 'feat/yours/main'],
},
];
for (const { name, a, b } of DIFFERENT) {
test(`不同的工作包:${name},不得撞名`, () => {
assert.notEqual(derive(...a), derive(...b));
});
}
test('分支名含斜線時路徑仍是單層目錄,不會長出巢狀結構', () => {
const path = derive('plugins/tea-sdlc', 'feat/a/b/c/main');
assert.equal(path.startsWith(`${HOME}/worktrees/`), true);
assert.equal(path.slice(`${HOME}/worktrees/`.length).includes('/'), false);
});
test('換一台機器只有家目錄會變,目錄名不變', () => {
process.env.TEA_SDLC_HOME = '/somewhere/else/.tea-sdlc';
const elsewhere = worktreePath('plugins/tea-sdlc', 'feat/mine/main');
delete process.env.TEA_SDLC_HOME;
const here = derive('plugins/tea-sdlc', 'feat/mine/main');
assert.equal(elsewhere.split('/').at(-1), here.split('/').at(-1));
});
// ── 與 CLI 釘在一起 ───────────────────────────────────────────────
test('branch-prep 印出的工作樹路徑就是這個函式算出來的那一條', async (t) => {
const repo = makeTempRepoWithRemote();
t.after(() => repo.cleanup());
mkdirSync(tmpRoot, { recursive: true });
const home = mkdtempSync(join(tmpRoot, 'home-'));
t.after(() => rmSync(home, { recursive: true, force: true }));
const { json } = await runScript(
'branch-prep.js',
['--repo', 'plugins/tea-sdlc', '--path', repo.dir, '--source', 'master',
'--type', 'feat', '--slug', 'mine', '--dry-run'],
{ env: { TEA_SDLC_HOME: home } },
);
process.env.TEA_SDLC_HOME = home;
const expected = worktreePath('plugins/tea-sdlc', 'feat/mine/main');
delete process.env.TEA_SDLC_HOME;
assert.equal(json.data.worktree, expected);
});