feat/overview-html-capture/main #68

Merged
admin merged 1 commits from feat/overview-html-capture/main into master 2026-09-21 09:42:50 +00:00
34 changed files with 100 additions and 6113 deletions
+3 -5
View File
@@ -1,7 +1,7 @@
# tea-sdlc
以 [tea](https://gitea.com/gitea/tea) 與 Gitea REST API 驅動 **SDLC 全流程**的跨平台指令組。
把規劃、分析、實作、修正、整併、工時回報六個階段,固定成可重複、可被任何 coding agent 執行的流程。
把規劃、分析、實作、修正、整併與報表停用流程,固定成可重複、可被任何 coding agent 執行的流程。
- **流程正本只有一份**:平台中立 markdown 放在 `prompts/`,改規則不會出現各平台版本分歧。
- **副作用集中**:所有對 Gitea 與 git 的呼叫下沉到 `scripts/` 的零相依 Node 腳本,統一 JSON 輸入輸出。
@@ -21,7 +21,7 @@
| `/sdlc-feat` | 領取工作包、開分支、逐項實作並開 PR |
| `/sdlc-fix` | 處理 PR 上的留言;收到議題編號則交棒給 `/sdlc-feat` |
| `/sdlc-sync` | 把散落在留言裡的決策整併回議題描述 |
| `/sdlc-report` | 產出週/月/年工時報表 |
| `/sdlc-report` | 週報/月報/年報目前不可用,回傳 `REPORT_UNAVAILABLE` |
---
@@ -54,9 +54,7 @@ tea-sdlc/
| 需求 | 用途 | 缺了會怎樣 |
| --- | --- | --- |
| Node.js ≥ 20 | 執行 `tea-sdlc` 與 `scripts/` | 連 `tea-sdlc` 都跑不起來 |
| git | 分支與 commit 操作 | `install` 照樣把轉接檔裝好,只在輸出裡列出缺的東西;流程指令中止並印出安裝指引 |
| [`tea`](https://gitea.com/gitea/tea) 並已登入 | Gitea 議題、標籤、Milestone、留言、工時 | 同上;登入用 `tea login add` |
| 目標 repo 已開啟時間追蹤 | 工時碼錶 | 流程指令中止,並指出 Settings → Advanced Settings → Enable Time Tracker |
| [`tea`](https://gitea.com/gitea/tea) 並已登入 | Gitea 議題、標籤、Milestone、留言 | 同上;登入用 `tea login add` |
| 帳號對目標 repo 的 issues unit 有寫入權 | 建立與更新議題 | 流程指令中止;Gitea 的 unit 權限獨立於 push 權限 |
---
+17 -287
View File
@@ -3,307 +3,37 @@ description: 僅由 /sdlc-analyze 指令叫用。對一顆需求議題執行可
# sdlc-analyze
對一顆需求議題執行可行性檢查,把疑點一題一題問到雙方有共識,再把共識變成一批工作包議題。
分成三段:**可行性分析**到共識摘要為止,除了起錶之外完全不寫入 Gitea;使用者看過摘要
點頭之後,才進入**產生工作包**建立議題;最後**排上時程與看板**,把相依、截止日、
Milestone、看板與人天估算補上。
這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。
## 輸入
一個需求議題編號。
## 計時範圍
錶起在**它分析的那顆需求議題**上:在「起錶」那一步起,在「停錶並回報」那一步停,
涵蓋整道 /sdlc-analyze。
起點放在第一段開頭,因為分析最耗時的正是共識之前那一段——四類疑點逐題問到收斂。
從第二段才起錶的話,那段時間永遠是零,而報表上「分析不花時間」會直接餵給下一次估算。
錶已經跑在同一顆議題上時起錶什麼都不做,所以 plan 接著跑 analyze 不會把累積的時間
切成兩段。**錶不跨階段跑**,也**不碰別顆議題上的錶**。
## 〔可委派〕的意思
標題後綴 `〔可委派〕` 的步驟只在意結果:**你的環境若能把工作交給子代理,就交出去,
只把結果帶回來;不能就自己做。** 沒有這個後綴的步驟一律自己做。
怎麼挑、為什麼這樣挑,見 `references/delegation.md`——判準只有那一份,這裡不複述。
一個需求議題編號。先用 `scripts/issue-extract.js` 讀取結構化內容;先看 `未處理留言數`。若有留言,直接走 `/sdlc-sync` 的流程,完成後自動接回這裡;不要要求使用者重打指令。接回前重新抽取一次拿到更新後的描述;若使用者不整併,繼續並在摘要註明。
## 第一段:可行性分析
### 1. 讀議題
依序讀取並逐條對照:
```
node scripts/issue-extract.js --repo <owner/name> --index <編號>
```
1. `references/feasibility-architecture.md`
2. `references/feasibility-logic.md`
3. `references/feasibility-data.md`
4. `references/feasibility-schedule.md`
拿到的是結構化欄位,不必再讀整份議題全文。
能從程式碼查證的事項自行查證;只有需要使用者決策的事項才提問。架構、邏輯、資料、時程四類依序完成,每次只問一題,每題提供建議與手動輸入。最後輸出共識摘要、變更假設、未決事項與人天估算;摘要只印終端,不寫入 Gitea。
**先看 `未處理留言數`。** 只要不是 0,就代表議題描述可能是過期的——留言裡有決策還沒被
整併回描述。這時**先停下來**告訴使用者有幾則未整併的留言,問他要不要現在整併。
要整併的話**直接走 `/sdlc-sync` 的流程**(`prompts/sdlc-sync.md`),做完**自動接回這裡**:
重新抽取一次拿到更新後的描述,再往下走。**不要要求使用者重打指令**——他已經說要整併了。
使用者選擇不整併就繼續,但要記下這件事,並在共識摘要裡註明「分析基於未整併留言前的描述」。
### 2. 起錶
議題確認存在之後,在**這顆需求議題**上起錶:
```
node scripts/timer.js --repo <owner/name> --index <需求議題編號> --dry-run
```
確認無誤後拿掉 `--dry-run` 再跑一次。錶已經跑在同一顆上時它什麼都不做,重跑不會把
已經累積的時間切成兩段。
錶跑在別顆議題上時會被擋下(`STOPWATCH_ON_OTHER_ISSUE`)。**照實告訴使用者是哪一顆,
請他自己去停**,不要代勞:那一段時間該記在哪顆議題上只有他知道。順帶說明停錶不會動到
任何既有的工作樹——碼錶只管時間、工作樹只管檔案。
### 3. 對四份清單列出疑點 〔可委派〕
依序讀這四份規則正本,逐條對照議題內容:
1. `references/feasibility-architecture.md` — 架構:放錯 repo、循環相依、穿越邊界。
2. `references/feasibility-logic.md` — 邏輯:既有功能是不是已經做過同一件事。
3. `references/feasibility-data.md` — 資料:schema 變更、遷移、交易邊界。
4. `references/feasibility-schedule.md` — 時程:相依鏈最長路徑、未知數最大的一項。
每一條檢查若在議題裡找不到答案,就轉成一個問題。**能在程式碼裡查證的就自己去查,
不要拿去問使用者**——把問題留給只有人能回答的事。
### 4. 逐題問到共識
**一次問一題。** 問完等使用者回答,再問下一題,讓他能看著前一題的答案回答下一題。
不要一次丟出五個問題,也不要把多個問題包成一題的多個選項。
順序固定為**架構 → 邏輯 → 資料 → 時程**,前一類的問題全部清空才進入下一類。
前面的答案常常會讓後面的問題消失或改寫;每問完一題,重新檢視剩下的問題還成不成立。
每一題固定給兩個選項:
- **建議** — 你的答案,附上理由。理由要寫「為什麼是這個」,不是複述問題。
- **手動輸入** — 讓使用者自己寫。任何一題都必須能手動作答,不被選項限制。
問題本身要具體到能用一句話回答。問不出收斂答案的問題,多半是問題本身太大,拆開再問。
### 5. 輸出共識摘要
全部問完後,輸出一份摘要讓使用者做最後確認,內容包含:
- **每一類的結論** — 架構/邏輯/資料/時程各自問出了什麼,逐條列出「問題 → 答案」。
- **改變了什麼** — 分析過程中翻掉或修正了需求議題裡的哪些假設。
- **仍然未決的事** — 問了但沒有答案、或使用者明確說「之後再說」的事。
- **人天估算** — 每一項的估算與最沒把握的那一項。
摘要只印在終端,**不寫回議題、不建立任何東西**。使用者看過點頭之後,才進入下一段。
使用者確認共識摘要後,**先列出要交付或驗收的項目,逐項向使用者確認**。使用者未確認或拒絕時,立即停止,不建立工作包、不排程、不寫入後續資料。
## 第二段:產生工作包
**使用者對共識摘要點頭之後才開始。** 摘要沒有經過確認就不要往下走。
確認交付/驗收項目後,依共識切出可獨立完成的工作包,套用 `templates/work-package-issue.md`。每顆工作包的待辦與驗收都要可逐項勾選;對應已確認交付項目的待辦置於第一項。工作包整體也依交付優先排序,但不得違反先決關係。
### 6. 切出工作包
每顆工作包先以 `scripts/issue-create.js --dry-run` 檢查,再移除旗標實跑。只使用既有標籤。建立後回報編號、標題與網址。
把需求切成幾顆工作包。一顆工作包是**開發者拿了就能動手、做完有明確結果**的單位:
它有自己的驗收標準,做完能單獨被檢視,不必等別的工作包一起才看得出成果。
## 第三段:排程
切的依據是第一段問出來的共識,特別是時程清單那份暫定拆法——那本來就是這一段的草稿。
以 `startDate`、`days` 與 `depends` 組成計畫檔,執行 `scripts/schedule.js` 計算截止日;依序用 `issue-link.js`、`issue-update.js` 與 `project-add.js` 補上既有相依、Milestone、看板、截止日與人天估算。各腳本先 dry-run,再實跑。日期與人天是排程資料,不是耗時統計。
**標題格式為「{動詞}{名詞}」**,例如「建立工作包的抽取契約」、「產生圖解版總覽網頁」。
**禁止流水編號與任何無意義代號**(`WP-01`、`任務三`、`第一階段`):命名本身就要說明用途,
看標題就知道這顆在做什麼,不必點進去。
### 7. 組出每顆工作包的內容
套用 `templates/work-package-issue.md`,依序填滿九個段落:
1. **這個工作包在做什麼** — 一句話。讓人掃過標題與這一行就決定要不要點進來。
2. **描述** — 從使用者的角度說這顆做完之後什麼事變得可能,不要寫成逐層的實作清單。
3. **架構圖** — 見下方「架構圖的限制」。
4. **範圍邊界** — 明列**不做什麼**。這一段的用途是抵抗範圍蔓延,寫得越具體越有用。
5. **介面契約** — 表格,四欄:介面/產出者/消費者/形狀。讓人知道自己產出的東西誰會消費。
這顆不產出對外介面就寫一列「無」,不要留空表。
6. **待辦** — 巢狀結構:每一項待辦底下掛**它自己的**驗收標準,讓人知道這一項做到什麼程度算完成。
```
- [ ] 建立共用函式庫
- [ ] 具名 flag 解析可拒絕未知參數
- [ ] 單行 JSON 輸出格式固定
- [ ] 加上前置檢查
- [ ] 四層各自回傳可區分的錯誤碼
```
上層是待辦、縮排一層是該項的驗收,不要再往下巢狀。兩者都用 checkbox,實作時會被逐項勾選。
7. **整體驗收** — 整顆工作包做完才驗得出來的事,與個別待辦的驗收不重複。
8. **repo 列表** — 這顆會動到哪些 repo。
9. **關聯** — 至少要有一行 `需求議題:#<編號>` 指回來源。阻擋、先決與人天估算由後續流程補上。
### 8. 先試跑,再寫入
每顆工作包各寫一個暫存檔,然後逐顆:
```
node scripts/issue-create.js --repo <owner/name> --title "<標題>" --body-file <暫存檔> \
--labels "<標籤>" --dry-run
```
`--dry-run` 會印出將送出的請求、把標籤名稱換成 id,並在標題已存在時如實顯示「實跑會是
no-op」。確認無誤後拿掉該旗標再跑一次。
標籤一樣只能從 `scripts/labels-list.js` 回傳的既有標籤裡挑,**不得自行建立新標籤**。
中斷後重跑不會產生重複工作包:`issue-create` 以標題查重,發現同名議題就回傳既有那一顆
並把 `created` 設為 `false`。
### 9. 回報
列出每顆工作包的編號、標題與網址。不要把議題內容再貼一次。
**這裡不停錶**——指令還沒跑完,第三段還要排時程。停錶在最後的「停錶並回報」那一步。
## 第三段:排上時程與看板
工作包建好之後,把它們之間的關係與時程補上。做完這一段,看板上呈現的才是真實的
開發順序,而不是一堆平鋪的議題。
### 10. 算出截止日 〔可委派〕
把每顆工作包的編號、人天估算與先決關係寫成一份計畫檔:
```json
{
"startDate": "2026-09-21",
"workPackages": [
{ "index": 12, "title": "建立共用函式庫", "days": 3 },
{ "index": 13, "title": "建立抽取契約", "days": 2, "depends": [12] }
]
}
```
```
node scripts/schedule.js --plan-file <計畫檔>
```
它依相依關係做拓撲排序,保證**任一工作包的截止日都不早於它的先決**——人工排時程
最常見的矛盾就是前置工作比後續還晚到期。相依成環時它會直接報錯並指出環上的成員,
那代表拆法有問題,回頭改拆法,不要硬排。
日期以日曆日累加,不跳週末也不扣假日。要跳的話自己把 `startDate` 或人天調整過再算。
### 11. 逐顆補上關係與時程
對每一顆工作包,依序:
```
node scripts/issue-link.js --repo <owner/name> --index <編號> --depends <先決編號清單>
node scripts/issue-update.js --repo <owner/name> --index <編號> \
--milestone "<既有 Milestone 名稱>" --due-date <schedule 算出的日期> --estimate-days <人天>
node scripts/project-add.js --repo <owner/name> --index <編號> --project "<看板名稱或網址>"
```
三支都先用 `--dry-run` 看過再實跑。三支都是冪等的:相依已存在就跳過、已在看板上就不重發、
估算沒變就不改 body。
**Milestone 與看板都只掛既有的。** 指到不存在的 Milestone 會中止並列出可選項目;
看板名稱靠掃最近 50 筆議題反查 id,反查不到就會請你直接貼專案網址(結尾即 id)。
本流程不建立 Milestone,也不建立專案。
### 12. 產生分析版的圖解總覽 〔可委派〕
排程完成後重新抽取需求與全部工作包,組成 `schemaVersion: 1` 的需求級 JSON。
工作包依賴與截止日必須來自重新抽取的實際資料,不使用模型記憶中的暫定值。
執行:
```
node scripts/overview-render.js --input <json> --output <html> --manifest <manifest>
```
HTML 內重新產生 SVG 與 HTML/CSS 視圖;不能直接搬用議題 Mermaid。預覽優先使用平台
能力,否則啟動短命 Node server 供瀏覽器截圖。產生 full-page 與局部圖 PNG 後執行:
```
node scripts/issue-assets.js --repo <owner/name> --index <需求議題編號> --manifest <manifest>
```
截圖全部上傳成附件,需求議題正文只保留附件索引。只有真正可用的 preview URL 才使用
既有 `--overview-url`;不可把 `.tmp/` 或 `0.0.0.0` 寫回 Gitea。
產出 HTML 可委派;預覽截圖、附件上傳與任何 `issue-assets`/`issue-update` 寫回都不委派,
由主流程執行並處理錯誤。
若平台 preview 不可用,執行 `node scripts/overview-capture.js --backend auto`;
它依序嘗試 Firefox,再嘗試 `rsvg-convert`/`resvg`。所有 backend 都不可用時必須
停止並保留 `.tmp/` 的 HTML/SVG,不得宣稱截圖完成。
### 13. 停錶並回報
回報之前先停錶,這一段計時到此為止:
```
node scripts/timer.js --repo <owner/name> --index <需求議題編號> --stop
```
它**只停這一顆**上的錶,與「起錶」那一步起的是同一顆。錶本來就沒在跑不算失敗
(`碼錶已停` 會是 `false` 並附一句說明),回報照樣做完。
列出每顆工作包的編號、標題、截止日與所屬 Milestone,並指出**相依鏈最長路徑**上的那幾顆
——那條路徑決定整體交期。
## 已知限制:人天估算只寫得進 body
Gitea 1.27 的 API 沒有任何請求定義接受 `time_estimate`,該欄位只出現在議題的回應裡。
也就是說**議題的估算欄位無法由 API 寫入**,只能靠人在網頁上填。
因此 `issue-update --estimate-days` 只把估算寫成議題 body 裡人類可讀的一行
(`估算人天:N`,放在「關聯」段落)。之後 `sdlc-report` 要比對估算與實際工時時,
讀的也是這一行。
## 架構圖的限制
圖表以抽象 `kind`、`direction`、`nodes`、`edges` 保存。renderer 產生議題 Mermaid
與 HTML SVG;不能讓模型直接維護兩套圖表語法。節點超過 12、文字超過 8 字或無法
安全轉換時,拆圖或記錄 `omitted` 原因。
## 邊界
- **共識摘要之前不對 Gitea 寫入任何內容**:不建議題、不改描述、不貼標籤、不留留言。
**碼錶除外**——它記的是工時,不是內容;而分析最耗時的正是共識之前那一段,不從那裡
起錶,那段時間就永遠是零。
- 第二段只建立工作包議題。不建相依、不掛 Milestone、不加看板、不寫人天估算——那是第三段的事。
- 第三段只掛既有的 Milestone 與看板。不自行建立標籤、Milestone 或專案看板。
- 不修改使用者的專案檔案。查證既有功能時只讀不寫。
- 不替使用者決定他沒回答的事。問不到答案就進「仍然未決的事」。
- 計時只動這顆需求議題:在它上面起錶、在它上面停錶。**不停別顆議題上的錶**,
被別顆的錶擋下時交還給使用者決定,不繞過去。
- 不關閉或刪除任何既有議題。
# Artifact 交接與視圖
完成第三段排程後,依 `references/artifact-contract.md` 重新抽取需求與全部工作包,
組成一份 `schemaVersion: 1` 的 JSON 暫存檔。這份 JSON 是 HTML renderer 的輸入,
不是 Gitea 的第二份正本。
先執行:
```
node scripts/overview-render.js --input <json> --output <html> --manifest <manifest>
```
renderer 驗證失敗時,不截圖、不上傳、不更新議題,保留 `.tmp/` 的輸入與錯誤供修正。
HTML 必須自包含,不依賴 CDN;圖表由抽象節點/邊資料重新產生 SVG,不能直接搬用議題
Mermaid。公式是可選欄位,單一公式無法渲染時保留原文並標示未渲染。
預覽優先使用執行環境提供的預覽能力。沒有預覽能力時,使用 `0.0.0.0` 綁定的
ephemeral Node 靜態伺服器,只為瀏覽器截圖,完成後一定停止。產生 full-page 與所有
局部圖 PNG,為每張圖填入 manifest 的角色、固定檔名與 SHA-256。
截圖全部是需求議題附件,不嵌入圖片;執行:
```
node scripts/issue-assets.js --repo <owner/name> --index <需求議題編號> --manifest <manifest>
```
這支腳本負責 hash 重用、附件上傳與「圖解版總覽附件」索引的冪等更新。上傳或索引
更新失敗就是流程失敗。只有預覽平台提供真正可用的 URL 時,才另外使用既有的
`--overview-url`;不可把 `.tmp/` 或 `0.0.0.0` 寫回 Gitea。
- 不產生 HTML、SVG、manifest、截圖、附件或任何平台 preview。
- 不建立 Milestone 或專案看板。
- 不修改使用者專案檔案。
- 不關閉或刪除既有議題。
- 共識摘要與交付確認前不建立任何工作包。
+7 -366
View File
@@ -1,375 +1,16 @@
name: sdlc-feat
description: 僅由 /sdlc-feat 指令叫用。領取一顆工作包、備妥工作樹、起錶,逐項實作並勾選待辦,最後分批提交並開立 PR。
description: 僅由 /sdlc-feat 指令叫用。領取工作包、開分支、逐項實作並開 PR。
# sdlc-feat
拿一顆工作包,從領取到開出 PR。
輸入一個工作包議題編號。若抽取結果有 `未處理留言數`,直接走 `/sdlc-sync` 的流程,完成後自動接回這裡;不要要求使用者重打指令。接回前重新抽取一次拿到更新後的描述。
第一段**領取與開工準備**:把工作包安全地認領下來,備妥一棵屬於它的工作樹,然後開始計時。
這一段不改任何一行程式碼——它只負責讓後面的實作有個乾淨的起點。
依工作包 `repos` 逐一準備 worktree;不在主工作區切換分支。逐項完成待辦並立即勾選對應驗收。交付優先項目位於待辦第一項時先完成;仍須遵守工作包的依賴與範圍邊界。
第二段**逐項實作**:一項一項把待辦做完並即時勾選,讓議題頁的進度條隨時反映真實狀態。
第三段**提交與開立 PR**:把變更整理成讀得懂的歷史,開出 PR,停錶。
這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。
## 輸入
一個工作包議題編號。使用者直接給的,或 `/sdlc-fix` 收到議題後交棒過來的——
兩者一樣處理,**不要因為是交棒來的就要求他再打一次指令**。
## 〔可委派〕的意思
標題後綴 `〔可委派〕` 的步驟只在意結果:**你的環境若能把工作交給子代理,就交出去,
只把結果帶回來;不能就自己做。** 沒有這個後綴的步驟一律自己做。
怎麼挑、為什麼這樣挑,見 `references/delegation.md`——判準只有那一份,這裡不複述。
## 第一段:領取與開工準備
### 1. 讀工作包
```
node scripts/wp-extract.js --repo <owner/name> --index <編號>
```
拿到的是結構化欄位:待辦與它自己的驗收、範圍邊界、介面契約、相依、repo 列表。
不必再讀整份議題全文。
**先看 `未處理留言數`。** 只要不是 0,就代表議題描述可能是過期的——留言裡有決策還沒被
整併回描述。這時**先停下來**告訴使用者有幾則未整併的留言,問他要不要現在整併。
要整併的話**直接走 `/sdlc-sync` 的流程**(`prompts/sdlc-sync.md`),做完**自動接回這裡**:
重新抽取一次拿到更新後的描述,再往下走。**不要要求使用者重打指令**——他已經說要整併了。
使用者選擇不整併就繼續,但要記下這件事,並在最後的 PR 描述裡註明「實作基於未整併留言前的描述」。
**再看 `相依.depends`。** 裡面還有沒關閉的議題,代表這顆的前置還沒做完。照樣先說出來,
讓使用者決定要不要現在做。
### 2. 領取工作包
先試跑,看清楚會做什麼:
```
node scripts/claim.js --repo <owner/name> --index <編號> --dry-run
```
確認無誤後拿掉旗標再跑一次。放行時它會設 assignee、貼「進行中」標籤——這兩件事一起
構成領取鎖。**錶不在這一步起**:它等工作樹建好之後才起(第 6 步)。工作樹建立失敗會
中止整個領取,錶要是先起了,使用者就被計了一段什麼都沒做的時間。
領取鎖有四種狀態,三種擋、一種放行。被擋下來時**不要繞過去**,照著錯誤碼告訴使用者
發生什麼事、下一步是什麼:
| 狀態 | 錯誤碼 | 下一步 |
| --- | --- | --- |
| 別人已經認領這顆 | `CLAIMED_BY_OTHER` | 改領別顆,或先跟對方確認 |
| 你的錶已經跑在這顆上 | `STOPWATCH_ON_THIS_ISSUE` | 這顆你正在做;要重新計時請先手動停錶 |
| 你的錶跑在別的議題上 | `STOPWATCH_ON_OTHER_ISSUE` | 多半是忘了停上一顆;先去停掉再回來 |
被錶擋下來時**要順帶說明停錶不會動到既有的工作樹**:碼錶只管時間、工作樹只管檔案。
不講清楚,使用者會以為停錶等於放棄那顆工作包,於是寧可不停——工時就記到別顆去了。
| 沒有鎖 | —— | 放行。自己已認領但沒起錶也算沒有鎖,那正是中斷後重跑的情形 |
**別顆議題上的錶一律由使用者自己停。** 哪一段時間該記在哪顆議題上只有他知道,代勞會把
工時記錯地方。每道指令停掉的只有自己起的那一支——這一道停在「開 PR 並停錶」那一步。
鎖以外還有一個前置條件:repo 上要有「進行中」標籤。缺了會得到 `LABEL_NOT_FOUND`,
請使用者自己去建立——**不要自己建**,標籤體系不該在多個 repo 之間長出雜草。
### 3. 問來源分支
**一次問一題。** 新分支要從哪裡長出來,只有使用者知道,不要替他決定。
給兩個選項,並附上你判斷的理由:
- **建議** — 你的答案。多數情況是開發分支(`master`/`main`/`develop`);
但若這顆工作包明顯是某個既有功能分支的一部分,就建議那一支,並說明為什麼。
- **手動輸入** — 讓使用者自己填分支名。
不論哪一種,來源分支都必須**已經在遠端上**:工作樹的起點一律取自 `origin/{來源分支}`。
### 4. 把議題標題翻成英文 〔可委派〕
分支名的中段要用英文,中文會讓 CI 與 URL 出問題。把工作包議題的標題翻成
**小寫英文 kebab、40 字元以內**,例如「建立工作包的抽取契約」→ `wp-extract-contract`。
翻譯要保留原意而不是逐字直譯,寧可用一個更短的說法,也不要把長句截斷成看不懂的字串。
### 5. 備妥工作樹
工作樹開在**工作包的 `repos` 列出的那些 repo** 上,不是開在本 plugin 的目錄裡。
`repos` 只有一顆就用那一顆;**有多顆時逐一確認**要在哪幾個開分支,
再對每一個各跑一次 `branch-prep`,分支名在每個 repo 都相同。
```
node scripts/branch-prep.js --repo <owner/name> --path <目標專案路徑> \
--source <來源分支> --slug <英文-kebab> [--type feat] --dry-run
```
`--type` 只在來源是開發分支時要給(`feat`/`fix`/`chore`…);從功能分支長出時,
類型與需求描述沿用來源,不必也不能再指定。
試跑會印出將執行的 git 指令、算出來的分支名與工作樹路徑。確認無誤後拿掉旗標再跑一次。
**不在原地切換分支,一律開一棵獨立的工作樹。** 每顆工作包有自己的目錄、自己的建置
產物、自己的未提交變更,彼此看不見對方。這件事對 agent 特別重要:它是非同步的,
可能在分支已經被切走之後才去讀檔,而它**不會察覺**自己讀到的是別顆工作包的內容——
產出看起來完全合理,只是接錯了上下文。
**工作樹一律建立,沒有例外。** 建不起來就照實中止,**不要改成在原地切分支**:
使用者會以為自己在隔離環境裡,其實在原地改。
工作樹路徑由 `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. 分批提交 〔可委派〕
全部待辦都勾完之後才進這一段。變更依類型分批。
委派的是**方案計算**:變更分成哪幾批、每一批收哪些檔案、各自的 `--type` 與描述怎麼寫。
**實際提交不委派**——底下那支 `commit-split.js` 由主流程執行(判準第四條)。
```
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
- 碼錶是否已停;沒停的話把腳本回的那句說明一起帶出來
- 議題上還有沒有沒勾完的待辦(理論上應該沒有;有的話要說出來)
### 17. 告訴使用者之後怎麼查
PR 開出去之後就交給 reviewer 了。**把下面這件事講給使用者聽,不要自己反覆跑**:
```
node scripts/pr-watch.js --repo <owner/name> --index <PR 編號>
```
問一次答一次:PR 狀態、還有幾則留言沒處理、工作樹在哪、裡面有沒有沒提交的東西,
以及固定列舉值的 `suggestedAction`(`run-sdlc-fix`/`cleanup`/`nothing-to-do`/
`blocked-dirty`)。多久跑一次由使用者自己排(cron 或他自己的循環機制),
本工具不長出排程器。
PR 合併或關閉時它會順手清掉那棵工作樹,**本機分支與遠端分支都留著**;工作樹裡還有
沒提交的東西就會擋下來(`blocked-dirty`),由使用者自己處理。永遠不會被合併也不會被
關閉的那些 PR,用手動出口清:
```
node scripts/worktree-remove.js --repo <owner/name> --branch <分支名>
```
完成後依既有 commit 分類規則提交,先用 `scripts/pr-create.js --dry-run` 檢查,再實跑開 PR。回報工作包、分支、worktree、完成待辦、commit 與 PR。
## 邊界
- 第一段**不改任何一行程式碼**、不勾待辦、不提交、不開 PR——那些是後面幾段的事。
- 第二段只實作與勾選。**不提交、不開 PR、不停錶**——那是第三段的事。
- 第三段不改任何一行程式碼。到這裡實作已經結束,要改就回第二段改完再來。
- **不在主工作區動手。** 第二段與第三段的每一個動作都在 `branch-prep` 建出來的那棵
工作樹裡進行,包含跑測試與 `--path`。
- 不把依賴、建置產物或 `.env` 這類機密檔案複製到工作樹裡,也不做連結——
兩棵工作樹共用同一份依賴,正好把工作樹要隔離的東西又接回去。
- 工作樹建不起來時中止,**不退回原地切分支**。
- 不把「已測試通過」這種空話寫進 PR 描述,也不為了通過檢查而拼湊內容。
- 不代替使用者決定 commit 的類型與描述;`--type` 與 `--subject` 都要是這次真的做了什麼。
- 不把實作規範或註解格式寫進目標專案的任何檔案。
- 不改與待辦無關的程式碼;順手想修的東西記下來說出來,不要摸進這次的變更裡。
- 不為了勾選在議題上留留言。
- **不自動反覆執行 `pr-watch`**,也不因為它建議了 `run-sdlc-fix` 就自己去跑 `/sdlc-fix`——
流程只由使用者明確叫用。
- 不自行建立標籤。缺「進行中」標籤時中止並請使用者建立。
- 不代替使用者停錶,也不在被鎖擋下時繞過去。
- 不替使用者決定來源分支。
- **不寫任何本機狀態檔。** 進度完全由 Gitea 上的 assignee、標籤、碼錶與 git 本身推導,
換一台機器或換一個 agent 都要能直接接手。
## 交接規格閘門
讀取工作包後,若它有介面契約,先完成第一個規格待辦:填妥介面、產出者、
消費者、形狀四欄,並附上該 `interfaceType` 的範例資料。四欄表格與範例資料
是同一待辦下的兩個驗收;兩者完成前不得進入後續程式實作。
資料、架構、排程工作包依其 `type` 先完成對應規格待辦;純內部小型實作可直接
進入下一個待辦。規格仍寫回工作包議題既有段落,不另建本機正本。
- 不修改工作包範圍外的檔案。
- 不切換主工作區分支。
- 不操作碼錶、不補登工時、不產生耗時統計。
+15 -163
View File
@@ -3,178 +3,30 @@ description: 僅由 /sdlc-plan 指令叫用。把一段口語需求轉成結構
# sdlc-plan
把使用者給的一段需求,變成一顆結構完整、下游指令讀得動的需求議題。
這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。
## 輸入
使用者給的東西可能是下列任一種,也可能三種混用:
- **自由文字** — 一段口語描述。
- **規格檔** — 一個檔案路徑,內容是既有的規格或筆記。
- **議題編號** — 既有議題的編號,用來補充脈絡或作為延伸的起點。
先把三種來源讀齊,再開始問問題。規格檔用檔案讀取工具讀;議題編號用
`scripts/issue-extract.js` 取(若該腳本尚未可用,改用 `scripts/issue-create.js` 以外的
既有讀取途徑,並在摘要中註明資料來源)。
## 計時範圍
這份正本把整個 /sdlc-plan 的耗時記成兩段,合起來就是這道指令實際花掉的時間:
- **議題建立之前** — 在「記下開始時間」記下起點,在「補登規劃時間,然後起錶」補上去。
議題還不存在,沒有標的可起錶。
- **議題建立之後** — 在「補登規劃時間,然後起錶」起錶,在「停錶並回報」停錶。
起與停都寫在這一份裡,**錶不跨階段跑**:跑完就去開會而錶跑一整天,報表當場失真。
反過來,別顆議題上的錶一律不碰——那一段時間該記在哪顆議題上只有使用者知道。
## 〔可委派〕的意思
標題後綴 `〔可委派〕` 的步驟只在意結果:**你的環境若能把工作交給子代理,就交出去,
只把結果帶回來;不能就自己做。** 沒有這個後綴的步驟一律自己做。
怎麼挑、為什麼這樣挑,見 `references/delegation.md`——判準只有那一份,這裡不複述。
把自由文字、規格檔或既有議題整理成需求議題。
## 步驟
### 1. 記下開始時間
讀齊輸入、逐項詢問、組出議題內容,往往是整個 plan 最耗時的一段,而它發生在議題建立
**之前**——那時候沒有標的可起錶。所以先把此刻的時間記下來,等議題建立之後補登上去:
1. 讀齊輸入,列出九個段落中已有依據與缺漏。
2. 一次問一題補齊缺漏;未獲回答的內容放入「未決事項」,不得自行編造。
3. 套用 `templates/requirement-issue.md`,填入總覽、背景、目標、非目標、領域名詞表、流程圖、驗收標準、影響範圍與未決事項。
4. 用 `scripts/labels-list.js` 取得既有標籤,只能選既有標籤。
5. 寫入前先執行:
```
node -e "console.log(new Date().toISOString())"
node scripts/issue-create.js --repo <owner/name> --title "<標題>" --body-file <暫存檔> --labels "<標籤>" --dry-run
```
記下它,一路帶到「補登規劃時間,然後起錶」那一步。**不要憑印象回推**:補登的長度就是
報表上規劃階段的數字。
確認內容後移除 `--dry-run` 實跑。重跑以標題查重,不建立重複議題。
### 2. 讀齊輸入,列出還缺什麼
## 流程圖限制
把九個段落逐一對照使用者給的材料,列出哪些段落已經有依據、哪些沒有。
### 3. 逐項詢問
**一次問一題**,等使用者回答完再問下一題,讓他能看著前一題的答案回答下一題。
每一題都附上你的建議與理由,讓使用者多數時候只要點頭;同時保留讓他自己寫答案的餘地。
**未獲得答覆的欄位不得自行編造。** 使用者沒說過的目標、沒提過的驗收標準,一個字都不能自己
填。問不到就放進「未決事項」,那一段本來就是給未決的東西用的。
### 4. 組出議題內容
套用 `templates/requirement-issue.md`,依序填滿九個段落:
1. **總覽** — 一句話講完這件事在做什麼,讓非技術的利害關係人不必讀完技術細節。圖解版總覽的
連結此時先留空,由後續流程回填。
2. **背景** — 不超過三行。為什麼現在要做這件事。
3. **目標** — 可量測。寫得出「怎樣算達成」才算數。
4. **非目標** — 明列這次不做什麼,用來抵抗範圍蔓延。
5. **領域名詞表** — 這份需求裡會反覆出現的詞,各給一行定義,讓團隊對同一個詞的理解一致。
6. **流程圖** — 見下方「流程圖的限制」。
7. **驗收標準** — 逐條列出,每一條都要能被驗證。
8. **影響範圍** — 會動到哪些 repo、哪些既有功能。
9. **未決事項** — 問不到答案、或需要他人拍板的事。
### 5. 挑標籤
先用 `scripts/labels-list.js --repo <owner/name>` 取得該 repo 的既有標籤,**只能從這份清單裡
挑**。找不到合適的就不貼。**不得自行建立新標籤** —— 標籤體系由專案維護者決定,不該在多個
repo 之間長出雜草。
### 6. 先試跑,再寫入
把組好的內容寫到一個暫存檔,然後:
```
node scripts/issue-create.js --repo <owner/name> --title "<標題>" --body-file <暫存檔> \
--labels "<標籤1,標籤2>" --dry-run
```
`--dry-run` 會印出將要送出的請求而不真的寫入。確認無誤後拿掉該旗標再跑一次。
同一段需求重跑不會產生第二顆議題:`issue-create` 以標題查重,發現同名議題就回傳既有那一顆
並把 `created` 設為 `false`。
### 7. 補登規劃時間,然後起錶
議題有了,計時才有標的。**先補登、再起錶**,兩步指向同一顆議題。
先把「記下開始時間」到議題建立那一段補上去:
```
node scripts/time-log.js --repo <owner/name> --index <編號> --since <記下的開始時間> --dry-run
```
長度由腳本自己算,不必自己做減法,也不會讓兩邊的時鐘各算一次。終點看議題是不是這一輪
建立的:是就補到**議題建立那一刻**,不是(對既有議題重跑)就補到**現在**——那一輪的
規劃時間照樣要進報表。確認無誤後拿掉 `--dry-run` 再跑一次。
**不設時間上限,照實補登。** 中途去開會的那兩個小時會一起被算進去,這是刻意的:換來
這個流程不必為此多長一題出來問使用者。時間記多了看得出來,記不到就永遠找不回來。
補登完才起錶:
```
node scripts/timer.js --repo <owner/name> --index <編號> --dry-run
```
一樣先試跑,確認無誤後拿掉 `--dry-run` 再跑一次。
順序不能反過來:錶一旦跑在這顆議題上,`time-log` 就會跳過不補——那一段已經有錶在記了,
再補一次會與錶涵蓋的區間重疊。**重跑是累計不是覆蓋**,每一輪各記一筆,報表上加總起來
才是這顆議題真正花掉的規劃時間。
錶已經跑在別顆議題上時起錶會被擋下(`STOPWATCH_ON_OTHER_ISSUE`)。**照實告訴使用者
是哪一顆,請他自己去停**,不要代勞:那一段時間該記在哪顆議題上只有他知道。順帶說明
停錶不會動到任何既有的工作樹——碼錶只管時間、工作樹只管檔案。
### 8. 產生圖解版總覽 〔可委派〕
依 `references/artifact-contract.md` 從需求議題抽取結果組成 `schemaVersion: 1` JSON,
執行 `node scripts/overview-render.js` 產生自包含 HTML 與 manifest。HTML 只給使用者檢視,
不取代需求議題的詳細內容。若平台有 preview 能力就使用它;否則以短命 Node server
服務 `.tmp/` 檔案供瀏覽器截圖。只有真正可用的 preview URL 才執行既有
`node scripts/issue-update.js --overview-url <網址>`。
產出 HTML 可委派;預覽截圖、附件上傳與任何 `issue-update` 寫回都不委派,由主流程
執行並處理錯誤。
規劃階段沒有工作包,因此 `workPackages` 為空陣列,不產生工作包依賴圖。
### 9. 停錶並回報
回報之前先停錶,這一段計時到此為止:
```
node scripts/timer.js --repo <owner/name> --index <編號> --stop
```
它**只停這一顆**上的錶。錶本來就沒在跑不算失敗(`碼錶已停` 會是 `false` 並附一句
說明),回報照樣做完。
把議題編號與網址告訴使用者。不要把整份議題內容再貼一次 —— 連結點進去就看得到。
## 流程圖的限制
流程圖的抽象節點與邊保存於 JSON,由 renderer 重新產生 SVG;議題若需要保存圖表,
由同一個 renderer 產生 Mermaid。節點數超過 12 或文字超過 8 字時拆圖或記錄
`omitted` 原因,不由模型任意壓縮語意。
流程圖只保留抽象節點與邊的文字描述;本流程不產生 HTML、SVG、manifest、截圖、附件或平台 preview。
## 邊界
- 不修改使用者的專案檔案。這個流程只讀輸入、寫 Gitea 議題。
- 不建立標籤、不建立 Milestone、不建立專案看板。
- 不關閉或刪除任何既有議題。
- 規劃階段本身已含問題釐清,因此寫入 Gitea 前不再設額外的確認點;`--dry-run` 就是那道關卡。
- 計時只動這顆需求議題:在它上面起錶、在它上面停錶、把規劃時間補登在它上面。
**不停別顆議題上的錶**,被別顆的錶擋下時交還給使用者決定,不繞過去。
## Artifact 產物規則
圖解總覽不再套用 HTML 模板或依賴 Mermaid CDN。若此階段需要產生預覽,
先依 `references/artifact-contract.md` 組成需求級 JSON,再執行
`node scripts/overview-render.js`。HTML、SVG 與 manifest 只寫入 `.tmp/`;
只有真正可用的 preview URL 才能使用既有 `--overview-url` 回寫。規劃階段沒有
工作包時,`workPackages` 必須是空陣列,工作包全景不產生。
- 不修改使用者專案檔案。
- 不建立標籤、Milestone 或專案看板。
- 不關閉或刪除既有議題。
- 不產生任何預覽或 artifact。
- 回報議題編號與網址,不重貼全文。
+7 -82
View File
@@ -1,91 +1,16 @@
name: sdlc-report
description: 僅由 /sdlc-report 指令叫用。產出本週、指定月份或指定年份的工時報表,只印在終端。
description: 僅由 /sdlc-report 指令叫用。週報、月報與年報目前不可用。
# sdlc-report
把 Gitea 上的碼錶紀錄整理成一份可以直接在週會上使用的工時報表。
週報、月報、年報目前不可用;時間追蹤功能已移除。
這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。
## 入口契約
## 輸入
執行 `scripts/report.js` 時仍使用既有腳本 JSON envelope,但固定回傳:
- **repo** — `owner/name`。沒給就問,不要猜。
- **期間** — 三選一,沒給就是本週:
- `--week` 本週一至今日(預設)
- `--month YYYY-MM` 指定月份,含 W1–W5 分段小計
- `--year YYYY` 指定年份,以月份分段小計
## 步驟
### 1. 取數字
```
node scripts/report.js --repo <owner/name> [--week | --month YYYY-MM | --year YYYY]
```json
{"ok":false,"error":{"code":"REPORT_UNAVAILABLE","message":"週報、月報、年報目前不可用;時間追蹤功能已移除。"}}
```
腳本回傳一行 JSON,裡面已經算好總計、分段小計與逐議題明細,**時分格式也一併算好了**
(`實際工時`、`落差工時`)。直接取用那些字串,不要自己再乘一次三千六百 —— 報表上的數字
自己算錯,比沒有報表更糟。
要回頭補印過去的某一週,加 `--today YYYY-MM-DD` 指定「今天」是哪一天。
### 2. 套模板印出
套用 `templates/report.md`,佔位對應如下:
- `{{期間}}` 期間標籤(`期間.標籤`)
- `{{範圍}}` 一行說明這份報表涵蓋哪個 repo、哪段日期、以幾小時當一人天
- `{{實際工時}}`、`{{估算人天}}`、`{{已估實際}}`、`{{落差}}` 取自 `總計`
- `{{分段}}` 每個分段一列表格列;**週報沒有分段,連同「分段小計」標題整段不印**——
markdown 表格只留表頭不留資料列,在終端上看起來像壞掉,不像「本來就沒有」
- `{{議題}}` 每顆議題一列表格列,議題欄寫成指回該議題的連結
- `{{附註}}` 見下方「怎麼讀落差」;沒有要提醒的就填「無」
報表**只印在終端**。不要張貼到議題、PR、聊天室或任何其他管道——這份要給誰看,是使用者的
決定,不是這個流程的。
### 3. 回報
印完就結束。不要順手去改議題、不要替使用者補登漏掉的工時。
## 期間怎麼切
三句話,沒有例外:
1. **一週為週一至週日。**
2. **跨月的那一週依「該週週五所屬月份」歸屬。** 一筆工時因此只會落在一個月裡,
不會被前後兩個月各算一次。
3. **W1–W5 指該週五是當月第幾個週五。** 當月有幾個週五就有幾段,有五個就排到 W5。
舉例:2026-01 的第一個週五是 01-02,所以 2025-12-29(週一)那天的工時算在 2026 年 1 月的
W1;2026-02-01(週日)那天的工時,它那一週的週五是 01-30,所以算在 2026 年 1 月的 W5,
而不是 2 月。
年報同理:跨年的那一週也依週五歸屬,2025-12-29 的工時會出現在 2026 年的報表裡。
## 怎麼讀落差
落差 = 實際工時 − 估算。**正數代表超出估算,負數代表還有餘裕。**
**總計的落差只涵蓋有估算的議題。** 分子是 `已估實際秒`(那些議題的實際工時)而不是 `實際秒`
(全部)——拿全部實際去比只有部分議題的估算,沒估算的工時會整批變成「超出估算」,落差就永遠
是灌水的正數。報表上把 `實際工時` 與 `已估實際` 並排印出來,兩者差多少就是沒估算的部分有多大。
估算讀的是議題「關聯」段落裡的「估算人天」那一行。換算時一人天預設為 8 小時,團隊若不是
這樣算,用 `--day-hours` 換掉。
有三件事要在 `{{附註}}` 裡講清楚,否則落差會被讀錯:
- **沒寫估算的議題,落差是空的,不是零。** 輸出裡是 `null`;一顆估算都沒有時,總計的落差也是
`null`,不要印成 0。
- **工作包還沒做完時,落差本來就會是負的。** 估算是整顆工作包的,實際卻只是這段期間內的
那一部分;只有工作包在這段期間內收掉,兩者才真的可以比。
- **`略過` 不為零時要說出來。** 那是查不到議題資訊的工時筆數,它們沒有被算進任何數字裡。
## 邊界
- 不張貼。報表只印在終端。
- 不寫入 Gitea:不改議題、不補登工時、不動碼錶。腳本唯一的非 GET,是四層前置檢查打在不存在的
議題 0 上那支寫入權探針,它不改動任何東西。
- 不替使用者決定跳過哪些日子。腳本只算實際記錄到的工時,不扣假日、不補上沒按碼錶的時間。
- 不跨 repo 彙總。一次一個 repo,要看別的就再跑一次。
不讀取 Gitea 工時、不計算期間、不產生 Markdown,不寫入議題、PR、聊天室或其他 artifact。
-55
View File
@@ -1,55 +0,0 @@
# Artifact 產物契約
這份規則是需求級 HTML、截圖與議題附件的唯一正本。
## 資料流
1. `/sdlc-analyze` 完成排程後重新取得需求與工作包抽取 JSON。
2. 流程組成一個 `schemaVersion: 1` 的需求根物件,暫存於 `.tmp/`。
3. `scripts/overview-render.js --input <json> --output <html> --manifest <manifest>` 驗證並產生自包含 HTML。
4. 預覽平台優先;沒有預覽能力時,使用可用 ephemeral port 短暫啟動 Node 靜態伺服器。
5. 瀏覽器產生 full-page 與圖表局部 PNG,寫入 manifest。
6. `scripts/issue-assets.js --repo ... --index ... --manifest ...` 上傳附件並就地更新需求議題附件索引。
JSON、HTML、SVG 與 PNG 全部寫入 `.tmp/`,不寫入目標專案。
## 根物件
必要欄位:`schemaVersion`、`source`、`requirement`、`workPackages`、`overview`。
`schemaVersion` 不是目前 renderer 支援的版本時必須停止;未知欄位保留。
## 圖表
圖表以抽象資料保存:`kind`、`direction`、`nodes`、`edges`。議題輸出由 renderer 產生 Mermaid;HTML 重新產生 SVG 或 HTML/CSS,不得直接搬用 Mermaid。
流程、依賴、架構與網路圖使用 SVG;卡片與清單使用 HTML/CSS。布局必須固定且可重跑。
## 公式
公式是可選欄位,保存 LaTeX 原文、標題、說明與用途。議題使用 `formula` fenced block;HTML 使用自包含 SVG。單一公式解析失敗時保留原文並標記未渲染,不得靜默遺失;公式欄位結構錯誤仍使整體 schema 驗證失敗。
## 截圖與附件
manifest 的每個 screenshot 必須包含 `role`、`path`、`fileName`、`sha256`。PNG 使用固定語意檔名,內容 hash 用 SHA-256。
full-page 與局部圖全部上傳為需求議題附件,不直接嵌入圖片。正文只保留一行附件索引;重跑時內容相同就重用,無法確認 hash 就新增附件。附件上傳或議題索引更新失敗是硬錯誤。
若平台提供真正可用的 preview URL,才使用既有 `--overview-url` 回寫;不可把 `.tmp/` 或 `0.0.0.0` 寫回議題。
## 截圖 backend
平台 preview 是首選。沒有平台 preview 時,可執行:
```
node scripts/overview-capture.js --backend auto --url <preview-url> --output <png>
```
若沒有瀏覽器但有 SVG rasterizer:
```
node scripts/overview-capture.js --backend svg --svg <overview.svg> --output <png>
```
`auto` 優先使用 Firefox,再使用 `rsvg-convert` 或 `resvg`。找不到任何 backend
必須失敗並保留 `.tmp/` 的 HTML/SVG,不得假裝已完成截圖。
+2 -41
View File
@@ -2,17 +2,9 @@
/**
* 領取一顆工作包:上鎖、貼標籤。
*
* 鎖用 assignee 加標籤,不用碼錶——Gitea 只讓人讀自己的碼錶(`/user/stopwatches`),
* 看不到別人的錶,拿它當鎖會漏判。碼錶在這裡只有一個用途:發現自己忘了停掉上一顆。
* 鎖用 assignee 加標籤,不用碼錶;碼錶與耗時統計已移除。
*
* **錶不在這一步起**。它等工作樹建好之後才由 timer.js 起動(見 branch-prep.js):
* 工作樹建立失敗會中止整個領取,錶要是先起了,使用者就被計了一段什麼都沒做的時間。
*
* 四種狀態的處置:
* - 他人已認領 → 擋。不會兩個人做同一件事。
* - 自己的錶跑在本議題 → 擋。這顆你已經在做了,別重複起錶。
* - 自己的錶跑在別的議題 → 擋。先去停掉那一顆,否則工時會記錯地方。
* - 沒有鎖(含自己已認領沒錶)→ 放行。後者正是中斷後重跑的情形。
* 他人已認領時擋下,自己已認領時可冪等重跑;所有判斷都在寫入前完成。
*
* 所有會擋的判斷都做在任何寫入之前:擋下來卻已經改了一半,比直接放行更難收拾。
* `--dry-run` 走的是同一條路,只是停在寫入之前——它印出的是這一顆此刻真正缺的那幾步,
@@ -27,15 +19,12 @@ import {
fetchIssue,
giteaRequest,
listLabels,
listStopwatches,
main,
parseFlags,
parseIndex,
parseRepo,
preflight,
resolveLogin,
stopwatchElsewhere,
stopwatchOnIssue,
} from './lib.js';
/** 領取鎖的另一半。本 plugin 不自動建立標籤,這個名字要在 repo 上先存在。 */
@@ -62,11 +51,7 @@ main(async () => {
const assignees = (issue.assignees ?? []).map((user) => user.login);
const labels = (issue.labels ?? []).map((label) => label.name);
// 所有會擋的判斷都做完才輪到寫入,試跑與實跑走同一條路——
// 試跑印得出漂亮的計畫、實跑卻被擋下來,那種落差最難查
checkClaimable(assignees, me, index);
await checkNoStopwatch(login, repo, index);
// 本 plugin 不建標籤,缺了就整件事不做,不要只設一半的鎖
const inProgress = (await listLabels(login, repo)).find((label) => label.name === IN_PROGRESS);
if (!inProgress) {
@@ -105,8 +90,6 @@ main(async () => {
url: issue.html_url,
assignee: me,
labels,
// 鎖上好了,錶還沒起:它等工作樹建好之後才由 timer.js 起動
碼錶中: false,
已認領過,
};
});
@@ -124,25 +107,3 @@ function checkClaimable(assignees, me, index) {
}
}
/**
* 自己的錶跑在任何議題上都擋,需要手動停錶後再領。
*
* 不代勞停錶:那一段時間該記在哪顆議題上只有人知道,腳本自作主張會把工時記錯地方。
* 錯誤碼分兩種,因為使用者的下一步不同——跑在本議題是「你已經在做了」,
* 跑在別的議題是「你忘了停掉那一顆」。
*/
async function checkNoStopwatch(login, repo, index) {
const watches = await listStopwatches(login);
if (watches.length === 0) return;
if (stopwatchOnIssue(watches, repo, index)) {
throw new ScriptError(
'STOPWATCH_ON_THIS_ISSUE',
`你的碼錶已經跑在議題 #${index} 上,這顆你正在做;` +
'若要重新計時,請先在 Gitea 上手動停錶再執行一次——' +
'停錶只停計時,不會動到你既有的工作樹',
);
}
throw stopwatchElsewhere(watches[0], '領取');
}
-96
View File
@@ -1,96 +0,0 @@
#!/usr/bin/env node
/**
* Upload overview screenshots to a Gitea issue and update its attachment index.
* The manifest is produced by the preview step and contains local PNG paths and hashes.
*/
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
ScriptError,
expectOk,
fetchIssue,
giteaRequest,
giteaUpload,
main,
parseFlags,
parseIndex,
parseRepo,
preflight,
resolveLogin,
} from './lib.js';
import { upsertLineInSection } from './issue-body.js';
const INDEX_LINE = '圖解版總覽附件:';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['repo', 'index', 'manifest'],
optional: ['host'],
booleans: ['dry-run'],
});
const repo = parseRepo(flags.repo);
const index = parseIndex(flags.index);
const manifest = JSON.parse(readFileSync(resolve(flags.manifest), 'utf8'));
const screenshots = manifest.screenshots;
if (!Array.isArray(screenshots) || screenshots.length === 0) {
throw new ScriptError('MANIFEST_INVALID', 'manifest 必須包含至少一張 screenshots');
}
for (const item of screenshots) {
if (!item.role || !item.path || !item.fileName || !item.sha256 || !existsSync(resolve(item.path))) {
throw new ScriptError('MANIFEST_INVALID', '每張截圖都必須有 role、path、fileName、sha256 且檔案存在');
}
}
const login = resolveLogin({ host: flags.host });
const issuePath = `/repos/${repo}/issues/${index}`;
const assetsPath = `${issuePath}/assets`;
if (flags['dry-run']) {
return {
dryRun: true,
repo,
index,
requests: [
{ method: 'GET', path: issuePath },
{ method: 'GET', path: assetsPath },
...screenshots.map((item) => ({ method: 'POST', path: assetsPath, file: item.path })),
],
};
}
await preflight(login, repo);
const issue = await fetchIssue(login, repo, index);
const assets = expectOk(await giteaRequest(login, 'GET', assetsPath), `GET ${assetsPath}`);
const existing = Array.isArray(assets) ? assets : [];
const indexed = parseIndexedAssets(issue.body ?? '');
const uploaded = [];
for (const item of screenshots) {
const indexedAsset = indexed.find((asset) => asset.fileName === item.fileName && asset.sha256 === item.sha256);
const existingAsset = existing.find((asset) => asset.name === item.fileName && asset.sha256 === item.sha256);
const asset = indexedAsset ?? existingAsset;
if (asset) {
uploaded.push({ ...item, url: asset.url ?? asset.browser_download_url ?? asset.download_url, reused: true });
continue;
}
const response = expectOk(
await giteaUpload(login, `${assetsPath}?name=${encodeURIComponent(item.fileName)}`, resolve(item.path), item.fileName),
`POST ${assetsPath}`,
);
uploaded.push({ ...item, url: response.browser_download_url ?? response.download_url, reused: false });
}
const body = updateIndex(issue.body ?? '', uploaded);
const update = body === issue.body ? null : expectOk(await giteaRequest(login, 'PATCH', issuePath, { body: { body } }), `PATCH ${issuePath}`);
return { repo, index, uploaded, updated: update !== null, url: update?.html_url ?? issue.html_url };
});
function parseIndexedAssets(body) {
const line = body.split('\n').find((row) => row.startsWith(INDEX_LINE)) ?? '';
return [...line.matchAll(/([^=;\s]+)=([^;\s]+)\s+sha256=([a-f0-9]{64})\s+(\S+)/g)].map((match) => ({
role: match[1],
fileName: match[2],
sha256: match[3],
url: match[4],
}));
}
function updateIndex(body, assets) {
const lines = assets.map((asset) => `${asset.role}=${asset.fileName} sha256=${asset.sha256} ${asset.url ?? '(網址未回傳)'}`);
return upsertLineInSection(body, '總覽', `${INDEX_LINE}${lines.join(';')}`);
}
+1 -92
View File
@@ -30,7 +30,7 @@ import { fileURLToPath } from 'node:url';
/** 帶錯誤碼的失敗。呼叫端靠 code 分辨是哪一步壞了,訊息則要能指出去哪裡改。 */
export class ScriptError extends Error {
/**
* @param {string} code 可區分的錯誤碼,例如 TIME_TRACKER_OFF
* @param {string} code 可區分的錯誤碼,例如 REPORT_UNAVAILABLE
* @param {string} message 給人看的訊息,必要時附上「該改哪裡」
*/
constructor(code, message) {
@@ -776,7 +776,6 @@ export async function preflight(login, repo) {
const user = await checkLogin(login);
const info = await fetchRepo(login, repo);
await checkIssueWrite(login, repo, info);
checkTimeTracker(info);
return { repo: info, user };
}
@@ -896,15 +895,6 @@ async function checkIssueWrite(login, repo, info) {
}
}
/** 第四層:repo 是否已開啟時間追蹤 */
function checkTimeTracker(info) {
if (info.internal_tracker?.enable_time_tracker !== true) {
throw new ScriptError(
'TIME_TRACKER_OFF',
'repo 尚未開啟時間追蹤,工時碼錶無法運作;請到 Settings → Advanced Settings → Enable Time Tracker 開啟',
);
}
}
/**
* 讀一個由 flag 指定的文字檔。
@@ -1013,87 +1003,6 @@ export async function mergedByMe(login, repo, id, me) {
return reactions.some((reaction) => reaction.content === '+1' && reaction.user?.login === me);
}
// ── 碼錶 ───────────────────────────────────────────────────────────
/**
* 目前跑在自己身上的碼錶。
*
* 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
);
}
/**
* 「這顆議題上沒有碼錶在跑」的回法不只一種:看過 500,也看過 409。
* 狀態碼隨站台版本而異,所以認的是「狀態碼在這一組裡 **且** 訊息說的是碼錶」——
* 只看訊息會把真的伺服器錯誤一起吞掉,只看狀態碼會把別的衝突也當成沒錶。
*/
const NO_STOPWATCH_STATUS = [409, 500];
/**
* 停錶。停在指定議題上,只停那一顆——端點本身就是議題範圍的,
* 停錶不會波及別顆議題上的錶,那正是領取鎖那條規則要守住的事。
*
* 錶沒在跑不算失敗:停錶多半排在別的事情做完之後(開完 PR、回報完),
* 把「本來就沒在跑」報成失敗,只會讓人以為前面那件事沒做成而重跑一次。
*
* @param {{base: string, token: string}} login
* @param {string} repo owner/name
* @param {number} index
* @returns {Promise<boolean>} 這次真的停了一支錶才是 true
*/
export async function stopStopwatch(login, repo, index) {
const path = `/repos/${repo}/issues/${index}/stopwatch/stop`;
const response = await giteaRequest(login, 'POST', path, { body: {} });
if (response.status >= 200 && response.status < 300) return true;
if (
NO_STOPWATCH_STATUS.includes(response.status) &&
/stopwatch/i.test(response.body?.message ?? '')
) {
return false;
}
expectOk(response, `POST ${path}`);
return false;
}
// ── 標籤 ───────────────────────────────────────────────────────────
-76
View File
@@ -1,76 +0,0 @@
#!/usr/bin/env node
/**
* Capture an overview using an available non-managed backend.
* Managed browser preview remains the preferred platform-level path; this CLI handles
* Firefox and deterministic SVG rasterization when those binaries are installed.
*/
import { execFileSync, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { ScriptError, main, parseFlags } from './lib.js';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['output'],
optional: ['url', 'svg', 'backend', 'width', 'height'],
});
const output = resolve(flags.output);
mkdirSync(dirname(output), { recursive: true });
const backend = chooseBackend(flags.backend, flags.url, flags.svg);
if (backend === 'firefox') captureFirefox(flags.url, output, flags.width, flags.height);
else captureSvg(flags.svg, output, flags.width, flags.height);
return { backend, output };
});
function chooseBackend(requested, url, svg) {
if (requested && requested !== 'auto' && requested !== 'firefox' && requested !== 'svg') {
throw new ScriptError('BACKEND_UNKNOWN', `不支援的 capture backend:${requested}`);
}
if (requested === 'firefox') {
requireBinary('firefox');
if (!url) throw new ScriptError('URL_REQUIRED', 'Firefox backend 需要 --url');
return 'firefox';
}
if (requested === 'svg') {
requireSvgInput(svg);
requireAnyBinary(['rsvg-convert', 'resvg']);
return 'svg';
}
if (url && hasBinary('firefox')) return 'firefox';
if (svg && (hasBinary('rsvg-convert') || hasBinary('resvg'))) return 'svg';
throw new ScriptError('NO_CAPTURE_BACKEND', '沒有可用的 Firefox 或 SVG rasterizer;請使用平台 preview,或安裝 firefox、rsvg-convert、resvg');
}
function captureFirefox(url, output, width, height) {
const args = ['--headless'];
if (width) args.push('--window-size', `${width}${height ? `,${height}` : ''}`);
args.push('--screenshot', output, url);
const result = spawnSync('firefox', args, { encoding: 'utf8' });
if (result.status !== 0 || !existsSync(output)) throw new ScriptError('CAPTURE_FAILED', `Firefox 截圖失敗:${result.stderr || result.stdout || '沒有輸出檔案'}`);
}
function captureSvg(svg, output, width, height) {
requireSvgInput(svg);
if (hasBinary('rsvg-convert')) {
const args = [`--output=${output}`];
if (width) args.push(`--width=${width}`);
if (height) args.push(`--height=${height}`);
args.push(resolve(svg));
const result = spawnSync('rsvg-convert', args, { encoding: 'utf8' });
if (result.status !== 0) throw new ScriptError('CAPTURE_FAILED', `rsvg-convert 失敗:${result.stderr || result.stdout}`);
} else {
const args = [];
if (width) args.push('-w', String(width));
args.push(resolve(svg), output);
const result = spawnSync('resvg', args, { encoding: 'utf8' });
if (result.status !== 0) throw new ScriptError('CAPTURE_FAILED', `resvg 失敗:${result.stderr || result.stdout}`);
}
if (!existsSync(output)) throw new ScriptError('CAPTURE_FAILED', 'rasterizer 沒有產生輸出檔案');
}
function requireSvgInput(svg) {
if (!svg || !existsSync(resolve(svg))) throw new ScriptError('SVG_REQUIRED', 'SVG backend 需要存在的 --svg');
}
function requireBinary(binary) { if (!hasBinary(binary)) throw new ScriptError('BINARY_NOT_FOUND', `找不到 ${binary}`); }
function requireAnyBinary(binaries) { if (!binaries.some(hasBinary)) throw new ScriptError('BINARY_NOT_FOUND', `找不到 ${binaries.join(' 或 ')}`); }
function hasBinary(binary) { try { execFileSync('sh', ['-c', `command -v ${binary}`], { stdio: 'ignore' }); return true; } catch { return false; } }
-144
View File
@@ -1,144 +0,0 @@
#!/usr/bin/env node
/**
* Validate an overview artifact JSON document and render a self-contained HTML file.
* The renderer never calls Gitea; callers provide a JSON snapshot from the extractors.
*/
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { ScriptError, main, parseFlags } from './lib.js';
const SCHEMA_VERSION = 1;
const WORK_TYPES = new Set(['implementation', 'data', 'architecture', 'schedule', 'documentation']);
const DIAGRAM_KINDS = new Set(['flowchart', 'dependency', 'architecture', 'network', 'repo', 'schedule']);
export function validateOverview(document) {
if (!document || typeof document !== 'object' || Array.isArray(document)) fail('SCHEMA_INVALID', '根資料必須是物件');
if (document.schemaVersion !== SCHEMA_VERSION) fail('SCHEMA_UNSUPPORTED', `只支援 schemaVersion ${SCHEMA_VERSION}`);
if (!document.source?.requirement?.repo || !Number.isInteger(document.source.requirement.index)) {
fail('SCHEMA_INVALID', 'source.requirement 必須包含 repo 與整數 index');
}
if (!document.requirement || typeof document.requirement !== 'object') fail('SCHEMA_INVALID', '缺少 requirement');
if (!Array.isArray(document.workPackages)) fail('SCHEMA_INVALID', 'workPackages 必須是陣列');
for (const [position, workPackage] of document.workPackages.entries()) validateWorkPackage(workPackage, position);
validateDiagrams(document.requirement.diagrams, 'requirement');
return document;
}
function validateWorkPackage(value, position) {
if (!value || typeof value !== 'object') fail('SCHEMA_INVALID', `workPackages[${position}] 必須是物件`);
for (const key of ['title', 'description', 'scope', 'issue']) {
if (typeof value[key] !== 'string' || value[key].trim() === '') fail('SCHEMA_INVALID', `workPackages[${position}].${key} 必填`);
}
if (!WORK_TYPES.has(value.type)) fail('SCHEMA_INVALID', `workPackages[${position}].type 不支援:${value.type}`);
for (const key of ['repos', 'depends', 'todos', 'acceptance']) {
if (!Array.isArray(value[key])) fail('SCHEMA_INVALID', `workPackages[${position}].${key} 必須是陣列`);
}
if (value.interfaces !== undefined) {
if (!Array.isArray(value.interfaces)) fail('SCHEMA_INVALID', `workPackages[${position}].interfaces 必須是陣列`);
for (const contract of value.interfaces) validateInterface(contract, position);
}
validateDiagrams(value.diagrams, `workPackages[${position}]`);
if (value.formulas !== undefined && !Array.isArray(value.formulas)) fail('SCHEMA_INVALID', `workPackages[${position}].formulas 必須是陣列`);
}
function validateInterface(contract, position) {
const kinds = {
http: ['method', 'path', 'request', 'response', 'errors', 'example'],
cli: ['command', 'args', 'stdout', 'stderr', 'exitCodes', 'example'],
function: ['signature', 'input', 'output', 'errors', 'example'],
event: ['topic', 'payload', 'producer', 'consumer', 'delivery', 'errors', 'example'],
storage: ['operation', 'entity', 'schema', 'constraints', 'transaction', 'example'],
};
if (!kinds[contract?.interfaceType]) fail('SCHEMA_INVALID', `workPackages[${position}] 有未知 interfaceType`);
for (const key of kinds[contract.interfaceType]) {
if (contract[key] === undefined) fail('SCHEMA_INVALID', `介面契約缺少 ${contract.interfaceType}.${key}`);
}
}
function validateDiagrams(diagrams, owner) {
if (diagrams === undefined) return;
if (!Array.isArray(diagrams)) fail('SCHEMA_INVALID', `${owner}.diagrams 必須是陣列`);
for (const diagram of diagrams) {
if (!DIAGRAM_KINDS.has(diagram?.kind)) fail('DIAGRAM_UNSUPPORTED', `${owner} 有未知圖表類型`);
if (!Array.isArray(diagram.nodes) || !Array.isArray(diagram.edges)) fail('SCHEMA_INVALID', `${owner} 圖表必須有 nodes 與 edges`);
const ids = new Set(diagram.nodes.map((node) => node.id));
for (const edge of diagram.edges) if (!ids.has(edge.from) || !ids.has(edge.to)) fail('SCHEMA_INVALID', `${owner} 圖表有不存在的邊端點`);
}
}
export function renderOverview(document) {
validateOverview(document);
const requirement = document.requirement;
const packages = document.workPackages;
const diagrams = [...(requirement.diagrams ?? []), ...packages.flatMap((workPackage) => workPackage.diagrams ?? [])];
const html = `<!doctype html><html lang="zh-Hant"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(requirement.title ?? '需求總覽')}</title><style>${styles()}</style></head><body><main><header><h1>${escapeHtml(requirement.title ?? '需求總覽')}</h1><p class="meta">來源:${escapeHtml(document.source.requirement.repo)}#${document.source.requirement.index}</p></header><p class="lede">${escapeHtml(requirement.summary ?? '')}</p>${renderListSection('目標', requirement.goals, 'goals')}${renderListSection('非目標', requirement.nonGoals, 'non-goals')}${renderDiagrams(diagrams)}<section id="work-packages"><h2>工作包</h2>${packages.map(renderWorkPackage).join('')}</section>${renderFormulas(requirement.formulas)}<footer>schemaVersion ${document.schemaVersion} · 產生時間 ${escapeHtml(document.overview?.generatedAt ?? '')}</footer></main></body></html>`;
return html;
}
export function renderFallbackSvg(document) {
validateOverview(document);
const diagrams = [...(document.requirement.diagrams ?? []), ...document.workPackages.flatMap((workPackage) => workPackage.diagrams ?? [])];
const height = 180 + diagrams.length * 300;
const diagramMarkup = diagrams.map((diagram, index) => `<g transform="translate(40 ${150 + index * 300})">${renderSvgContents(diagram)}</g>`).join('');
return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="${height}" viewBox="0 0 1200 ${height}"><rect width="100%" height="100%" fill="#ffffff"/><text x="40" y="55" font-size="30" font-family="sans-serif">${escapeHtml(document.requirement.title ?? '需求總覽')}</text><text x="40" y="95" font-size="18" font-family="sans-serif">${escapeHtml(document.requirement.summary ?? '')}</text>${diagramMarkup}</svg>`;
}
function renderWorkPackage(workPackage, index) {
const slug = slugify(workPackage.title) || `work-package-${index + 1}`;
const done = workPackage.todos.filter((todo) => todo.done).length;
return `<article id="work-package-${slug}"><h3>${escapeHtml(workPackage.title)}</h3><p>${escapeHtml(workPackage.description)}</p><dl><dt>類型</dt><dd>${escapeHtml(workPackage.type)}</dd><dt>進度</dt><dd>${done}/${workPackage.todos.length} 項待辦</dd><dt>依賴</dt><dd>${escapeHtml(workPackage.depends.join(', ') || '無')}</dd><dt>repo</dt><dd>${escapeHtml(workPackage.repos.join(', ') || '無')}</dd></dl><p><a href="${escapeAttr(workPackage.issue)}">查看工作包詳細內容</a></p></article>`;
}
function renderDiagrams(diagrams) {
return diagrams.map((diagram, index) => `<section id="diagram-${index + 1}"><h2>${escapeHtml(diagram.title ?? diagram.kind)}</h2><div class="diagram">${renderSvg(diagram)}</div>${diagram.omitted ? `<p class="omitted">未產生:${escapeHtml(diagram.omitted)}</p>` : ''}</section>`).join('');
}
function renderSvg(diagram) {
const columns = Math.max(1, Math.ceil(Math.sqrt(diagram.nodes.length || 1)));
const width = Math.max(640, columns * 220);
const height = Math.max(180, Math.ceil((diagram.nodes.length || 1) / columns) * 100 + 80);
const positions = new Map(diagram.nodes.map((node, index) => [node.id, { x: 30 + (index % columns) * 210, y: 35 + Math.floor(index / columns) * 100 }]));
const edges = diagram.edges.map((edge) => { const from = positions.get(edge.from); const to = positions.get(edge.to); return from && to ? `<line x1="${from.x + 150}" y1="${from.y + 24}" x2="${to.x}" y2="${to.y + 24}" marker-end="url(#arrow)"/><text x="${(from.x + to.x + 150) / 2}" y="${(from.y + to.y) / 2 + 18}">${escapeHtml(edge.label ?? '')}</text>` : ''; }).join('');
const nodes = diagram.nodes.map((node) => { const point = positions.get(node.id); return `<g><rect x="${point.x}" y="${point.y}" width="150" height="48" rx="8"/><text x="${point.x + 75}" y="${point.y + 29}">${escapeHtml(node.label ?? node.id)}</text></g>`; }).join('');
return `<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeAttr(diagram.title ?? diagram.kind)}"><defs><marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z"/></marker></defs>${edges}${nodes}</svg>`;
}
function renderSvgContents(diagram) {
return renderSvg(diagram).replace(/^<svg[^>]*>/, '').replace(/<\/svg>$/, '');
}
function renderListSection(title, values, id) { if (!Array.isArray(values) || values.length === 0) return ''; return `<section id="${id}"><h2>${title}</h2><ul>${values.map((value) => `<li>${escapeHtml(typeof value === 'string' ? value : value.text ?? '')}</li>`).join('')}</ul></section>`; }
function renderFormulas(formulas) {
if (!Array.isArray(formulas) || formulas.length === 0) return '';
return `<section id="formulas"><h2>公式</h2>${formulas.map((formula) => {
const latex = String(formula.latex ?? '');
const supported = /^[A-Za-z0-9\s+\-*/=().,_^{}]+$/.test(latex);
return `<article class="formula"><h3>${escapeHtml(formula.title ?? '公式')}</h3><p>${escapeHtml(formula.description ?? '')}</p>${supported ? `<svg class="formula-svg" viewBox="0 0 700 60" role="img" aria-label="${escapeAttr(latex)}"><text x="12" y="38">${escapeHtml(latex)}</text></svg>` : `<p class="omitted">公式未渲染,原文如下:</p><pre>${escapeHtml(latex)}</pre>`}</article>`;
}).join('')}</section>`;
}
function styles() { return `:root{color-scheme:light dark;--bg:#fff;--fg:#1f2328;--muted:#59636e;--line:#d1d9e0;--surface:#f6f8fa;--accent:#0969da}*{box-sizing:border-box}body{margin:0;padding:48px 16px 96px;background:var(--bg);color:var(--fg);font:16px/1.7 -apple-system,"Noto Sans TC","Microsoft JhengHei",sans-serif}main{max-width:980px;margin:0 auto}header{border-bottom:1px solid var(--line);padding-bottom:24px;margin-bottom:40px}h1{font-size:30px;line-height:1.3}h2{font-size:15px;letter-spacing:.08em;color:var(--muted);margin:32px 0 16px}h3{line-height:1.4}.meta,.omitted{color:var(--muted)}.lede{font-size:21px;padding:20px 24px;background:var(--surface);border-left:3px solid var(--accent)}ul{padding-left:24px}.diagram{padding:20px;background:var(--surface);border:1px solid var(--line);border-radius:8px;overflow:auto}.diagram svg{display:block;min-width:620px;height:auto}.diagram rect{fill:var(--bg);stroke:var(--accent);stroke-width:2}.diagram text{fill:var(--fg);font-size:14px;text-anchor:middle}.diagram line{stroke:var(--accent);stroke-width:2}.diagram marker path{fill:var(--accent)}article{border-top:1px solid var(--line);padding:16px 0}dl{display:grid;grid-template-columns:max-content 1fr;gap:4px 16px;color:var(--muted)}dt{font-weight:600}dd{margin:0}a{color:var(--accent)}pre{white-space:pre-wrap;background:var(--surface);padding:12px;border-radius:6px}footer{margin-top:56px;padding-top:20px;border-top:1px solid var(--line);color:var(--muted);font-size:13px}@media (prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--muted:#9198a1;--line:#3d444d;--surface:#151b23;--accent:#4493f8}}`; }
function slugify(value) { return String(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }
function escapeHtml(value) { return String(value ?? '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;'); }
const escapeAttr = escapeHtml;
function fail(code, message) { throw new ScriptError(code, message); }
if (process.argv[1] === new URL(import.meta.url).pathname) {
main(async () => {
const flags = parseFlags(process.argv.slice(2), { required: ['input', 'output'], optional: ['manifest', 'svg'] });
const inputPath = resolve(flags.input);
const outputPath = resolve(flags.output);
const document = JSON.parse(readFileSync(inputPath, 'utf8'));
validateOverview(document);
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, renderOverview(document));
const result = { schemaVersion: SCHEMA_VERSION, html: outputPath, sha256: createHash('sha256').update(readFileSync(outputPath)).digest('hex') };
if (flags.svg) {
const svgPath = resolve(flags.svg);
mkdirSync(dirname(svgPath), { recursive: true });
writeFileSync(svgPath, renderFallbackSvg(document));
result.svg = svgPath;
}
if (flags.manifest) { mkdirSync(dirname(resolve(flags.manifest)), { recursive: true }); writeFileSync(resolve(flags.manifest), `${JSON.stringify({ ...result, screenshots: [] }, null, 2)}\n`); }
return result;
});
}
Regular → Executable
+18 -191
View File
@@ -1,80 +1,8 @@
#!/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 {
ScriptError,
expectOk,
giteaRequest,
main,
parseFlags,
parseIndex,
parseRepo,
preflight,
readTextFile,
resolveLogin,
stopStopwatch,
} from './lib.js';
import { ScriptError, expectOk, giteaRequest, main, parseFlags, parseIndex, parseRepo, preflight, readTextFile, resolveLogin } from './lib.js';
/** 描述的固定段落,順序即 reviewer 閱讀的順序 */
const SECTIONS = [
'摘要',
'需求議題',
'工作包議題',
'變更內容',
'設計重點',
'解決的問題',
'影響的功能',
'測試結果',
];
/**
* 「測試結果」裡等於沒寫的那幾句。
* 不是窮舉,是擋住最常見的偷懶寫法——真的跑過的話,貼輸出比打這幾個字還快。
*/
const EMPTY_TALK = new Set([
'無',
'沒有',
'N/A',
'n/a',
'已測試',
'已測試通過',
'測試通過',
'測試皆通過',
'測試皆已通過',
'全部通過',
'全數通過',
'皆通過',
'無異常',
'沒有問題',
'一切正常',
'正常',
'ok',
'OK',
]);
const SECTIONS = ['摘要', '需求議題', '工作包議題', '變更內容', '設計重點', '解決的問題', '影響的功能', '測試結果'];
const EMPTY_TALK = new Set(['無', '沒有', 'N/A', 'n/a', '已測試', '已測試通過', '測試通過', '正常', 'ok', 'OK']);
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
@@ -83,132 +11,31 @@ main(async () => {
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 = readTextFile(flags['body-file'], '--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 payload = { title: flags.head, head: flags.head, base: flags.base, body };
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: {} },
],
};
}
if (flags['dry-run']) return { dryRun: true, repo, issueRepo, index, head: flags.head, base: flags.base, title: flags.head, requests: [{ method: 'POST', path: pullsPath, body: payload }] };
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, issueRepo, index);
return {
repo,
issueRepo,
index,
created: existing === null,
title: pull.title,
url: pull.html_url,
number: pull.number,
head,
base,
碼錶已停: stopped,
...(stopped ? {} : { note: '碼錶本來就沒在這顆議題上運轉,PR 已經在了,這一步略過。' }),
};
const existing = await findOpenPull(login, repo, flags.head);
const pull = existing ?? expectOk(await giteaRequest(login, 'POST', pullsPath, { body: payload }), `POST ${pullsPath}`);
return { repo, issueRepo, index, created: existing === null, title: pull.title, url: pull.html_url, number: pull.number, head: flags.head, base: flags.base };
});
/**
* 找同一個 head 上開著的 PR。
* 重跑時 Gitea 會對重複的 PR 回 422,而那個錯誤看不出「其實已經開好了」——
* 先查一次,重跑就是安靜地接上。
*/
function checkSections(body) {
const headings = [...body.matchAll(/^## (.+)$/gm)].map((match) => match[1].trim());
if (headings.length !== SECTIONS.length || headings.some((heading, i) => heading !== SECTIONS[i])) throw new ScriptError('BAD_PR_BODY', `PR 描述必須依序包含:${SECTIONS.join('、')}`);
}
function checkTestResult(body) {
const match = body.match(/^## 測試結果\s*\n([\s\S]*?)(?=^## |$)/m);
if (!match || EMPTY_TALK.has(match[1].trim())) throw new ScriptError('BAD_PR_TEST_RESULT', '測試結果必須填入實際執行的命令與輸出');
}
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;
}
/** 八個段落一個都不能少,而且順序要與 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 自己能重現的' +
'手動驗證步驟。「已測試通過」這種寫法看不出跑過什麼,等於沒寫',
);
}
const pulls = expectOk(await giteaRequest(login, 'GET', path, { query: { state: 'open' } }), `GET ${path}`) ?? [];
return pulls.find((pull) => pull.head?.label === head || pull.head?.ref === head) ?? null;
}
Regular → Executable
+8 -374
View File
@@ -1,384 +1,18 @@
#!/usr/bin/env node
/**
* 產出工時報表:本週、指定月份或指定年份。
*
* 只印在終端,不對任何管道張貼——給誰看是使用者的決定,不是這支腳本的。
* 這支腳本自己只讀不寫;唯一的非 GET 是四層前置檢查裡那支探測寫入權的 PATCH
* (打在不存在的議題 0 上,不會改動任何東西),那是全專案共用的前置檢查,不是報表在寫東西。
*
* 期間怎麼切是這支腳本唯一的難處,規則固定成三句話:
* 一週為週一至週日;跨月的那一週依「該週週五所屬月份」歸屬;
* W1–W5 指該週五是當月第幾個週五。
* 週五當錨點的好處是一筆工時只會落在一個月裡,跨月週不會被兩邊各算一次。
*
* 工時來源是 `/user/times`——它永遠只回傳自己的工時,不必有 issue manager 權限,
* 也就不會把別人的工時混進自己的報表。repo 的篩選因此在本地做。
*
* 估算讀的是議題「關聯」段落裡的「估算人天」那一行,不是 Gitea 的 time_estimate 欄位:
* 該欄位的 API 寫不進去(見 issue-update 的說明),議題上唯一可信的估算就是那一行。
*
* 用法:
* node scripts/report.js --repo owner/name
* [--week | --month YYYY-MM | --year YYYY] [--today YYYY-MM-DD]
* [--day-hours 8] [--host <網址>] [--dry-run]
* 週報、月報、年報目前停用。
* 時間追蹤與耗時統計已移除,不能再產生可信的工時報表。
*/
import {
ScriptError,
fetchIssue,
main,
pages,
parseFlags,
parseRepo,
preflight,
resolveLogin,
} from './lib.js';
import { labelledNumber, parseSections } from './issue-body.js';
import { ScriptError, main, parseFlags, parseRepo } from './lib.js';
/** 一人天預設幾小時。跳不跳假日是團隊政策,這裡只給一個可被 --day-hours 換掉的預設。 */
const DEFAULT_DAY_HOURS = 8;
const TIMES_PATH = '/user/times';
const REPORT_UNAVAILABLE = '週報、月報、年報目前不可用;時間追蹤功能已移除。';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['repo'],
optional: ['month', 'year', 'today', 'day-hours', 'host'],
booleans: ['week', 'dry-run'],
optional: ['month', 'year', 'today', 'host'],
booleans: ['week'],
});
const repo = parseRepo(flags.repo);
const dayHours = parseDayHours(flags['day-hours']);
const period = resolvePeriod(flags);
if (flags['dry-run']) {
return {
dryRun: true,
repo,
期間: publicPeriod(period),
requests: [{ method: 'GET', path: TIMES_PATH }],
};
}
const login = resolveLogin({ host: flags.host });
await preflight(login, repo);
const entries = await fetchTimes(login, period);
const bodies = await fetchMissingBodies(login, repo, period, entries);
return summarise({ repo, period, dayHours, entries, bodies });
parseRepo(flags.repo);
throw new ScriptError('REPORT_UNAVAILABLE', REPORT_UNAVAILABLE);
});
// ── 期間 ───────────────────────────────────────────────────────────
/**
* 把三個互斥的期間 flag 收斂成一段日期範圍與它的分段。
* @returns {{類型: string, 標籤: string, 起: string, 迄: string, 分段: {名稱: string, 起: string, 迄: string}[]}}
*/
function resolvePeriod(flags) {
const chosen = ['week', 'month', 'year'].filter((name) => flags[name] !== undefined);
if (chosen.length > 1) {
throw new ScriptError(
'PERIOD_CONFLICT',
`--week、--month、--year 三選一,收到的是 ${chosen.map((n) => `--${n}`).join(' 與 ')}`,
);
}
// --today 只決定「本週」是哪一週,對月報年報毫無作用。默默忽略一個使用者明確給的值,
// 會讓他以為報表切在別的地方;寧可擋下來。
if (flags.today !== undefined && (flags.month !== undefined || flags.year !== undefined)) {
throw new ScriptError('PERIOD_CONFLICT', '--today 只搭配 --week 使用,月報與年份報表用不到它');
}
if (flags.month !== undefined) return monthPeriod(parseMonth(flags.month));
if (flags.year !== undefined) return yearPeriod(parseYear(flags.year));
return weekPeriod(parseToday(flags.today));
}
/** 本週:本週一至今日。還沒發生的日子不該出現在報表的期間裡。 */
function weekPeriod(today) {
const start = mondayOf(today);
return { 類型: 'week', 標籤: `${start} ~ ${today}`, 起: start, 迄: today, 分段: [] };
}
/** 月報:以當月的每個週五各拉出一週,週一至週日 */
function monthPeriod(month) {
const weeks = fridaysIn(month).map((friday, i) => ({
名稱: `W${i + 1}`,
起: addDays(friday, -4),
迄: addDays(friday, 2),
}));
return { 類型: 'month', 標籤: month, 起: weeks[0].起, 迄: weeks.at(-1).迄, 分段: weeks };
}
/** 年報:十二個月各自套月報的切法,分段小計到月為止 */
function yearPeriod(year) {
const months = Array.from({ length: 12 }, (_, i) => {
const month = `${year}-${String(i + 1).padStart(2, '0')}`;
const { 起, 迄 } = monthPeriod(month);
return { 名稱: month, 起, 迄 };
});
return { 類型: 'year', 標籤: String(year), 起: months[0].起, 迄: months.at(-1).迄, 分段: months };
}
/** 當月的所有週五,由早到晚 */
function fridaysIn(month) {
const [year, index] = month.split('-').map(Number);
const fridays = [];
for (let day = 1; day <= 31; day += 1) {
const date = new Date(Date.UTC(year, index - 1, day));
if (date.getUTCMonth() !== index - 1) break;
if (date.getUTCDay() === 5) fridays.push(iso(date));
}
return fridays;
}
// ── 期間參數的把關 ─────────────────────────────────────────────────
function parseMonth(value) {
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(value)) {
throw new ScriptError('BAD_PERIOD', `--month 需為 YYYY-MM,收到的是 ${value}`);
}
return value;
}
function parseYear(value) {
if (!/^\d{4}$/.test(value)) {
throw new ScriptError('BAD_PERIOD', `--year 需為四位數年份,收到的是 ${value}`);
}
return Number(value);
}
/** 沒給就取系統日期的「今天」。給了就以它為準,讓報表能回頭補印過去的某一週。 */
function parseToday(value) {
if (value === undefined) return localDate(new Date());
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || iso(new Date(`${value}T00:00:00Z`)) !== value) {
throw new ScriptError('BAD_PERIOD', `--today 需為真實存在的 YYYY-MM-DD,收到的是 ${value}`);
}
return value;
}
function parseDayHours(value) {
if (value === undefined) return DEFAULT_DAY_HOURS;
const hours = Number(value);
if (!Number.isFinite(hours) || hours <= 0) {
throw new ScriptError('BAD_DAY_HOURS', `--day-hours 需為正數,收到的是 ${value}`);
}
return hours;
}
// ── 日期算術 ───────────────────────────────────────────────────────
//
// 一律以 YYYY-MM-DD 字串進出、以 UTC 的 Date 當中間格式:日曆上的「哪一天」
// 不該被本機時區的日光節約搬動。時區只在一個地方出現——把工時的時刻換算成
// 「使用者那天」的 localDate。
function iso(date) {
return date.toISOString().slice(0, 10);
}
function addDays(date, days) {
const moment = new Date(`${date}T00:00:00Z`);
moment.setUTCDate(moment.getUTCDate() + days);
return iso(moment);
}
/** 該日期所屬那一週的週一。週界以週一切,週日屬於前面那一週。 */
function mondayOf(date) {
const weekday = new Date(`${date}T00:00:00Z`).getUTCDay();
return addDays(date, -((weekday + 6) % 7));
}
/** 時刻 → 使用者在的時區裡的那一天。週界是以人在的時區切的,不是 UTC。 */
function localDate(moment) {
const year = moment.getFullYear();
const month = String(moment.getMonth() + 1).padStart(2, '0');
const day = String(moment.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/** 某一天的本地零時,轉成 Gitea 要的 RFC 3339 */
function startOfDay(date) {
const [year, month, day] = date.split('-').map(Number);
return new Date(year, month - 1, day, 0, 0, 0, 0).toISOString();
}
/** 某一天的本地尾聲,轉成 Gitea 要的 RFC 3339 */
function endOfDay(date) {
const [year, month, day] = date.split('-').map(Number);
return new Date(year, month - 1, day, 23, 59, 59, 999).toISOString();
}
// ── 取工時 ─────────────────────────────────────────────────────────
/**
* 取回期間內、屬於自己的所有工時。
* since/before 只是先讓伺服器砍掉大半;真正的期間判斷仍在本地做,
* 因為期間是以使用者的時區切的,而伺服器不知道使用者在哪個時區。
*/
async function fetchTimes(login, period) {
const entries = [];
for await (const page of pages(login, TIMES_PATH, {
query: { since: startOfDay(period.起), before: endOfDay(period.迄) },
limitCode: 'TIME_LIMIT',
limitHint: `${TIMES_PATH} 的工時筆數超出可走訪範圍,這份報表會是不完整的`,
})) {
entries.push(...page);
}
return entries;
}
/**
* 補齊估算讀不到的議題 body。
*
* 估算只存在於議題 body 的那一行,而 `/user/times` 內嵌的議題不保證帶 body——
* 少了它,整份報表的估算與落差會靜靜地全變成 null,而報表仍然回報成功。
* 因此缺 body 的議題各補一次 GET:筆數是「這段期間碰過的議題數」,不是工時筆數。
*/
async function fetchMissingBodies(login, repo, period, entries) {
const missing = new Set();
for (const entry of entries) {
const issue = entry.issue;
if (!issue || issue.number === undefined) continue;
if (issue.repository?.full_name !== repo || issue.body !== undefined) continue;
const date = localDate(new Date(entry.created));
if (date >= period.起 && date <= period.迄) missing.add(issue.number);
}
const bodies = new Map();
for (const index of missing) {
bodies.set(index, (await fetchIssue(login, repo, index)).body ?? '');
}
return bodies;
}
// ── 彙總 ───────────────────────────────────────────────────────────
function summarise({ repo, period, dayHours, entries, bodies }) {
const { total, skipped, bySegment, byIssue } = collect({ repo, period, entries, bodies });
// 工時多的排前面:週會上先講的是吃掉最多時間的那一顆
const issues = [...byIssue.values()]
.sort((a, b) => b.實際秒 - a.實際秒 || a.index - b.index)
.map((issue) => publicIssue(issue, dayHours));
const estimated = issues.filter((issue) => issue.估算人天 !== null);
const estimatedDays = estimated.reduce((sum, issue) => sum + issue.估算人天, 0);
const estimatedActual = estimated.reduce((sum, issue) => sum + issue.實際秒, 0);
// 總計的落差只拿「有估算的那些議題」的實際去比。拿全部實際去比只有部分議題的估算,
// 會讓沒估算的工時全部變成「超出估算」,落差就永遠是灌水的正數。
const gap = estimated.length === 0 ? null : gapSeconds(estimatedActual, estimatedDays, dayHours);
return {
repo,
期間: publicPeriod(period),
每日工時: dayHours,
總計: {
實際秒: total,
實際工時: formatHours(total),
估算人天: estimatedDays,
已估實際秒: estimatedActual,
已估實際工時: formatHours(estimatedActual),
落差秒: gap,
落差工時: gap === null ? null : formatGap(gap),
},
分段: period.分段.map((segment) => ({
名稱: segment.名稱,
起: segment.起,
迄: segment.迄,
實際秒: bySegment.get(segment.名稱),
實際工時: formatHours(bySegment.get(segment.名稱)),
})),
議題: issues,
略過: skipped,
};
}
/**
* 把工時逐筆歸到週次與議題底下。
* 期間外的、別的 repo 的都在這裡被濾掉;查不到議題資訊的則被數起來——
* 它們不歸到任何數字,但也不能無聲消失。
*/
function collect({ repo, period, entries, bodies }) {
const bySegment = new Map(period.分段.map((segment) => [segment.名稱, 0]));
const byIssue = new Map();
let skipped = 0;
let total = 0;
for (const entry of entries) {
const date = localDate(new Date(entry.created));
if (date < period.起 || date > period.迄) continue;
const issue = entry.issue;
if (!issue?.repository?.full_name || issue.number === undefined) {
skipped += 1;
continue;
}
if (issue.repository.full_name !== repo) continue;
const seconds = Number(entry.time) || 0;
total += seconds;
const segment = period.分段.find((s) => date >= s.起 && date <= s.迄);
if (segment) bySegment.set(segment.名稱, bySegment.get(segment.名稱) + seconds);
const known = byIssue.get(issue.number);
if (known) {
known.實際秒 += seconds;
} else {
byIssue.set(issue.number, {
index: issue.number,
title: issue.title ?? '',
url: issue.html_url ?? '',
實際秒: seconds,
估算人天: estimateDays(issue.body ?? bodies.get(issue.number)),
});
}
}
return { total, skipped, bySegment, byIssue };
}
/** 期間的對外形狀不含分段定義——分段的數字在 data.分段 裡,不必重複一份 */
function publicPeriod(period) {
return { 類型: period.類型, 標籤: period.標籤, 起: period.起, 迄: period.迄 };
}
/** 落差 = 實際 − 估算。正數是超出估算,負數是還有餘裕。 */
function gapSeconds(actualSeconds, days, dayHours) {
return actualSeconds - days * dayHours * 3600;
}
/** 逐議題那一列的對外形狀。沒有估算就沒有落差,填 null 而非零:零會被讀成「剛好準」。 */
function publicIssue(issue, dayHours) {
const gap = issue.估算人天 === null ? null : gapSeconds(issue.實際秒, issue.估算人天, dayHours);
return {
index: issue.index,
title: issue.title,
url: issue.url,
實際秒: issue.實際秒,
實際工時: formatHours(issue.實際秒),
估算人天: issue.估算人天,
落差秒: gap,
落差工時: gap === null ? null : formatGap(gap),
};
}
/**
* 從議題 body 讀出估算人天。
* 只認「關聯」段落裡的那一行——那是 issue-update 唯一寫得進去的位置,
* 其他地方出現的數字(例如描述裡順手提到的「大概三天」)不算數。
* @returns {number|null} 沒寫估算時為 null
*/
function estimateDays(body) {
return labelledNumber(parseSections(body ?? ''), '關聯', '估算人天');
}
/** 秒 → 「3h 30m」。秒數不進位成分鐘,免得湊出假的精確。 */
function formatHours(seconds) {
const minutes = Math.floor(Math.abs(seconds) / 60);
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`;
}
/** 落差要一眼看出方向:超出估算帶 +,還有餘裕帶 − */
function formatGap(seconds) {
if (seconds === 0) return formatHours(0);
return `${seconds > 0 ? '+' : '-'}${formatHours(seconds)}`;
}
-132
View File
@@ -1,132 +0,0 @@
#!/usr/bin/env node
/**
* 補登一段沒有錶記到的工時。
*
* 規劃階段最耗時的那一段——讀齊輸入、逐項詢問、組出議題內容——發生在議題建立**之前**,
* 那時候沒有標的可起錶(議題還不存在)。這段時間只能事後補登,否則報表上的規劃永遠是零,
* 久了會讓人以為規劃不花時間,而那正是估算失準最常見的來源。
*
* **長度由這支腳本自己算**:終點減掉 `--since`。交給 agent 做減法,等於讓兩邊的時鐘與
* 時區各算一次,而算錯了報表上看不出來。
*
* 終點取哪一刻,看議題是不是這一輪建立的:
* - 議題建立於 `--since` 之後 → 終點是**議題的建立時間**。這是第一次跑,補的正是
* 「指令開始到議題建立」那一段。
* - 議題比 `--since` 還早 → 終點是**補登的當下**。這是對既有議題重跑,那一輪的規劃
* 時間照樣要進報表;拿舊的建立時間當終點會算出負數,等於把這一輪的工夫丟掉。
*
* **不設時間上限,照實補登。** 中途去開會的那兩個小時會一起被算進去——換來這個流程
* 不必為此多長一題出來問使用者。時間記多了看得出來,記不到就永遠找不回來。
*
* **重跑會累計,不會覆蓋**:每一輪各記一筆,報表上加總起來才是這顆議題真正花掉的規劃
* 時間。唯一跳過的情形是自己的錶已經跑在這顆議題上——補登排在起錶之前,錶在跑就代表
* 這一輪已經走到起錶那一步了,再補一次會與錶涵蓋的區間重疊。
*
* 只寫工時,不動任何錶——別顆議題上有錶在跑也照補,那兩件事互不相干。
*
* 用法:
* node scripts/time-log.js --repo owner/name --index 42 --since <ISO 8601 時間>
* [--host <網址>] [--dry-run]
*/
import {
ScriptError,
expectOk,
fetchIssue,
giteaRequest,
listStopwatches,
main,
parseFlags,
parseIndex,
parseRepo,
preflight,
resolveLogin,
stopwatchOnIssue,
} from './lib.js';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['repo', 'index', 'since'],
optional: ['host'],
booleans: ['dry-run'],
});
const repo = parseRepo(flags.repo);
const index = parseIndex(flags.index);
const since = parseSince(flags.since);
const dryRun = flags['dry-run'] === true;
const timesPath = `/repos/${repo}/issues/${index}/times`;
const login = resolveLogin({ host: flags.host });
// 試跑照樣讀現況:手寫一份固定的清單會跟實作走鐘,也說不出「錶已經在跑了」
if (!dryRun) await preflight(login, repo);
const issue = await fetchIssue(login, repo, index);
const 建立時間 = Date.parse(issue.created_at);
if (Number.isNaN(建立時間)) {
throw new ScriptError(
'NO_CREATED_AT',
`${repo} 的議題 ${index} 沒有可解讀的建立時間,補登的終點判斷不出來`,
);
}
// 這一輪建立的議題就補到建立那一刻;既有的議題則補到現在,那一輪的工夫一樣要進報表
const 這輪建立 = 建立時間 > since;
const 迄 = 這輪建立 ? 建立時間 : Date.now();
const 秒數 = Math.round((迄 - since) / 1000);
const 略過 = await skipReason(login, repo, index, 秒數);
const planned = 略過 === null ? [{ method: 'POST', path: timesPath, body: { time: 秒數 } }] : [];
const 報告 = {
repo,
index: issue.number,
title: issue.title,
url: issue.html_url,
since: new Date(since).toISOString(),
迄: new Date(迄).toISOString(),
依據: 這輪建立 ? '議題建立' : '補登當下',
秒數,
補登: 略過 === null,
...(略過 ? { note: 略過 } : {}),
};
if (dryRun) {
return { dryRun: true, ...報告, requests: planned };
}
for (const { method, path, body } of planned) {
expectOk(await giteaRequest(login, method, path, { body }), `${method} ${path}`);
}
return 報告;
});
/**
* 不該補的理由,沒有就回 null。
*
* 只有兩種:長度非正的那一段根本不存在;錶已經跑在這顆議題上,代表這一輪已經走到起錶
* 那一步,再補就與錶涵蓋的區間重疊。重跑本身不是理由——每一輪的規劃時間都要記上去。
*/
async function skipReason(login, repo, index, 秒數) {
if (秒數 <= 0) {
return '指令開始時間不早於現在,沒有可補登的區間;兩邊時鐘差幾秒是常事,這不算失敗。';
}
if (stopwatchOnIssue(await listStopwatches(login), repo, index)) {
return '碼錶已經跑在這顆議題上。補登排在起錶之前,錶在跑就代表這一輪補過了,補下去會與錶重疊。';
}
return null;
}
/**
* 解析 `--since`。擋在打 Gitea 之前:值打錯是最常見的輸入錯誤,
* 而它在補登之前唯一的症狀就是長度不對,事後從報表上看不出來。
* @returns {number} epoch 毫秒
*/
function parseSince(value) {
const at = Date.parse(value);
if (Number.isNaN(at)) {
throw new ScriptError(
'BAD_SINCE',
`--since 需為可解析的 ISO 8601 時間(例如 2026-09-17T10:05:00Z),收到的是 ${value}`,
);
}
return at;
}
-96
View File
@@ -1,96 +0,0 @@
#!/usr/bin/env node
/**
* 起錶與停錶。
*
* 錶與領取鎖是兩件事:鎖用 assignee 加標籤(見 claim.js),錶只管工時。
*
* **領取工作包時**(sdlc-feat)兩者的時機不同:鎖要在開工之前就上好,錶則要等到
* **工作樹真的建好之後**才起。工作樹建立失敗會中止整個領取,錶要是先起了,使用者就被
* 計了一段什麼都沒做的時間,而工時要準正是工時報表的立足點。規劃與分析沒有工作樹,
* 那條規則對它們不適用——它們的標的是需求議題本身,議題存在就起得了錶。
*
* 自己的錶跑在別顆議題上時擋下,不代勞停錶:那一段時間該記在哪顆議題上只有人知道,
* 腳本自作主張會把工時記錯地方。錶已經跑在本議題上則什麼都不做——重新起錶會把已經
* 累積的時間切成兩段,而中斷後重跑正是這支腳本最常見的處境。
*
* `--stop` 停錶,而且**只停 `--index` 指的那一顆**。每個階段停掉自己起的那支錶,
* 錶就不會跨階段跑——跑完就去開會而錶跑一整天,報表當場失真。反過來,別顆議題上的錶
* 一律不碰:Gitea 在別顆議題上起新錶會靜默地停掉並記錄前一顆,那種靜默結算正是
* 領取鎖那條規則當初要擋的,這裡不能反過來製造它。
*
* 停錶時錶本來就沒在跑不算失敗:這一步多半排在別的事情做完之後(開完 PR、回報之前),
* 把「本來就沒在跑」報成失敗,只會讓人以為前面那件事沒做成而重跑一次。
*
* 用法:
* node scripts/timer.js --repo owner/name --index 40 [--stop] [--host <網址>] [--dry-run]
*/
import {
expectOk,
fetchIssue,
giteaRequest,
listStopwatches,
main,
parseFlags,
parseIndex,
parseRepo,
preflight,
resolveLogin,
stopStopwatch,
stopwatchElsewhere,
stopwatchOnIssue,
} from './lib.js';
main(async () => {
const flags = parseFlags(process.argv.slice(2), {
required: ['repo', 'index'],
optional: ['host'],
booleans: ['stop', '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 要停錶 = flags.stop === 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 動作 = 要停錶
? { 端點: 'stop', 要發請求: 已在計時 }
: { 端點: 'start', 要發請求: !已在計時 };
const planned = 動作.要發請求
? [{ method: 'POST', path: `${issuePath}/stopwatch/${動作.端點}`, body: {} }]
: [];
const 報告 = { repo, index: issue.number, title: issue.title, url: issue.html_url };
if (dryRun) {
return { dryRun: true, ...報告, requests: planned, 已在計時 };
}
if (要停錶) {
// 端點回「沒有錶在跑」的狀態碼隨站台版本而異,所以停錶走 lib 那條容錯路徑;
// 讀到的現況與實際狀態差一步(錶剛被別處停掉)也不該把整件事報成失敗
const stopped = planned.length > 0 && (await stopStopwatch(login, repo, index));
return {
...報告,
碼錶已停: stopped,
...(stopped ? {} : { note: '碼錶本來就沒在這顆議題上運轉,這一步略過;別顆議題上的錶不由這裡代停。' }),
};
}
for (const { method, path, body } of planned) {
expectOk(await giteaRequest(login, method, path, { body }), `${method} ${path}`);
}
return { ...報告, 碼錶中: true, 已在計時 };
});
+1 -16
View File
@@ -6,7 +6,7 @@
* 1. 待辦是巢狀的——每一項待辦底下掛它自己的驗收,並各自帶回未經修改的 `raw`,
* 下游靠 `raw` 做精確字串替換來勾選 checkbox,只改那一行,不重寫整份 body。
* 2. 介面契約是四欄表格,四欄都要留著。
* 3. body 說不出的三個活狀態要現查:相依、領取人、碼錶。
* 3. body 說不出的兩個活狀態要現查:相依與領取人。
*
* 用法:
* node scripts/wp-extract.js --repo owner/name --index 9 [--host <網址>] [--dry-run]
@@ -15,7 +15,6 @@ import {
UNMERGED_COMMENT_NOTE,
countUnmergedComments,
fetchIssue,
listStopwatches,
main,
pages,
parseFlags,
@@ -23,7 +22,6 @@ import {
parseRepo,
preflight,
resolveLogin,
stopwatchOnIssue,
} from './lib.js';
import {
checklistInSection,
@@ -56,7 +54,6 @@ main(async () => {
{ method: 'GET', path: issuePath },
{ method: 'GET', path: `${issuePath}/dependencies` },
{ method: 'GET', path: `${issuePath}/blocks` },
{ method: 'GET', path: '/user/stopwatches' },
{ method: 'GET', path: `${issuePath}/comments` },
],
note: UNMERGED_COMMENT_NOTE,
@@ -86,8 +83,6 @@ main(async () => {
整體驗收: listSection(sections, '整體驗收'),
repos: listSection(sections, 'repo 列表'),
相依: { blocks, depends },
assignee: issue.assignee?.login ?? null,
碼錶中: await hasRunningStopwatch(login, repo, index),
未處理留言數: await countUnmergedComments(login, repo, index, user.login),
};
});
@@ -110,13 +105,3 @@ async function fetchLinked(login, path, kind) {
return indexes;
}
/**
* 這顆議題上是不是有碼錶在跑。
*
* Gitea 只讓人讀自己的碼錶(`/user/stopwatches`),所以這個欄位的真正語意是
* 「**我**的碼錶正跑在這顆議題上」。它用來提醒自己忘了停錶,不是用來判斷別人有沒有在做
* ——領取鎖看的是 assignee。
*/
async function hasRunningStopwatch(login, repo, index) {
return stopwatchOnIssue(await listStopwatches(login), repo, index) !== null;
}
+4 -22
View File
@@ -1,25 +1,7 @@
# 工時報表 {{期間}}
# 工時報表
{{範圍}}
**不可用**
## 總計
週報、月報、年報目前不可用;時間追蹤功能已移除。
| 實際工時 | 估算人天 | 已估實際 | 落差 |
| --- | --- | --- | --- |
| {{實際工時}} | {{估算人天}} | {{已估實際}} | {{落差}} |
## 分段小計
| 段 | 起迄 | 實際工時 |
| --- | --- | --- |
{{分段}}
## 逐議題
| 議題 | 標題 | 實際工時 | 估算人天 | 落差 |
| --- | --- | --- | --- | --- |
{{議題}}
## 附註
{{附註}}
狀態碼:`REPORT_UNAVAILABLE`
-312
View File
@@ -1,312 +0,0 @@
/**
* 領取工作包的鎖。
*
* 錶不在這一支起——它等工作樹建好之後才由 timer.js 起動,所以這裡連帶要驗
* 「一發起錶請求都沒有」:領取失敗或工作樹建不起來時,使用者不該被計一段
* 什麼都沒做的時間。
*
* 這一支的價值全在「什麼時候擋下來」:放行的路徑只有一條,擋的理由有四種,
* 而擋錯的代價是兩個人做同一件事、或是工時記到別顆議題上。所以決策表的四種狀態
* 各有測試,而且每一種都要驗「一個字都沒寫進 Gitea」——擋下來卻已經改了一半,
* 比直接放行更難收拾。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { runScript } from './helpers/run-script.js';
import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js';
const REPO = 'plugins/tea-sdlc';
const INDEX = 11;
const ME = 'tester';
/** 議題上跑著的碼錶長什麼樣 */
const stopwatchOn = (index, repo = REPO) => ({
issue_index: index,
repo_owner_name: repo.split('/')[0],
repo_name: repo.split('/')[1],
});
function routes(overrides = {}, options = {}) {
const { assignees = [], labels = [], stopwatches = [], repoLabels } = options;
const base = healthyRoutes(REPO, {
'GET /api/v1/user': { status: 200, body: { login: ME } },
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
status: 200,
body: {
number: INDEX,
title: '以 sdlc-feat 領取工作包、起錶並備妥分支',
html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/${INDEX}`,
assignees: assignees.map((login) => ({ login })),
labels: labels.map((name, i) => ({ id: 60 + i, name })),
},
},
'GET /api/v1/user/stopwatches': { status: 200, body: stopwatches },
[`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 201, body: {} },
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/labels`]: { status: 200, body: [] },
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/start`]: { status: 201, body: {} },
});
if (repoLabels !== undefined) {
base[`GET /api/v1/repos/${REPO}/labels`] = {
status: 200,
body: repoLabels.map((name, i) => ({ id: 55 + i, name })),
};
}
return { ...base, ...overrides };
}
const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options));
const run = (args, stub) =>
runScript('claim.js', ['--repo', REPO, '--index', String(INDEX), ...args], {
env: envFor(stub),
});
/**
* 會改動 Gitea 的請求;擋下來的情境裡這些一個都不該出現。
* 前置檢查對 `issues/0` 的那一發 PATCH 不算數——它是探權限用的,打在一顆不存在的議題上,
* 不會改動任何東西(見 lib.js 的 checkIssueWrite)。
*/
const writes = (stub) =>
stub.requests.filter((r) => r.method !== 'GET').filter((r) => !r.path.endsWith('/issues/0'));
// ── 決策表:無鎖 ───────────────────────────────────────────────────
test('沒有鎖時放行:設 assignee、貼進行中', async (t) => {
const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent', '進行中'] });
const { code, json } = await run([], stub);
assert.equal(code, 0);
assert.equal(json.data.assignee, ME);
assert.deepEqual(json.data.labels, ['進行中']);
assert.equal(json.data.碼錶中, false, '鎖上好了,錶還沒起');
assert.equal(json.data.已認領過, false);
});
test('放行時只寫入鎖的那兩件事,一發起錶請求都沒有', async (t) => {
const stub = await withStub(t, {}, { repoLabels: ['進行中'] });
await run([], stub);
assert.deepEqual(
writes(stub).map((r) => `${r.method} ${r.path}`),
[
`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`,
`POST /api/v1/repos/${REPO}/issues/${INDEX}/labels`,
],
'錶等工作樹建好之後才由 timer.js 起動:建不起來就中止,不該已經計了時間',
);
});
test('assignee 送的是自己的帳號,標籤送的是 id 不是名字', async (t) => {
const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent', '進行中'] });
await run([], stub);
const patch = writes(stub).find((r) => r.method === 'PATCH');
assert.deepEqual(patch.body.assignees, [ME]);
const label = writes(stub).find((r) => r.path.endsWith('/labels'));
assert.deepEqual(label.body.labels, [56], '進行中在假 repo 上的 id 是 56');
});
// ── 決策表:他人已認領 ─────────────────────────────────────────────
test('他人已認領時擋下,並指名是誰', async (t) => {
const stub = await withStub(t, {}, { assignees: ['someone-else'], repoLabels: ['進行中'] });
const { code, json } = await run([], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'CLAIMED_BY_OTHER');
assert.match(json.error.message, /someone-else/);
assert.deepEqual(writes(stub), [], '擋下來就不該寫進任何東西');
});
test('自己在 assignee 裡但還有別人時,一樣擋', async (t) => {
const stub = await withStub(t, {}, { assignees: [ME, 'someone-else'], repoLabels: ['進行中'] });
const { json } = await run([], stub);
assert.equal(json.error.code, 'CLAIMED_BY_OTHER');
});
// ── 決策表:自己的碼錶在跑 ─────────────────────────────────────────
test('自己碼錶跑在本議題時擋下,要求先手動停錶', async (t) => {
const stub = await withStub(t, {}, {
stopwatches: [stopwatchOn(INDEX)],
repoLabels: ['進行中'],
});
const { code, json } = await run([], stub);
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), []);
});
test('自己碼錶跑在別的議題時擋下,並指出是哪一顆', async (t) => {
const stub = await withStub(t, {}, {
stopwatches: [stopwatchOn(7)],
repoLabels: ['進行中'],
});
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.match(
json.error.message,
/停錶只停計時,不會動到任何既有的工作樹/,
'以為停錶等於放棄那顆工作包的人會寧可不停,工時就記到別顆去了',
);
assert.deepEqual(writes(stub), []);
});
test('別的 repo 上的同號碼錶也算自己有錶在跑', async (t) => {
const stub = await withStub(t, {}, {
stopwatches: [stopwatchOn(INDEX, 'plugins/別的專案')],
repoLabels: ['進行中'],
});
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, {}, { assignees: [ME], repoLabels: ['進行中'] });
const { code, json } = await run([], stub);
assert.equal(code, 0);
assert.equal(json.data.已認領過, true);
assert.equal(json.data.碼錶中, false, '重跑時鎖照樣補齊,錶則仍舊留到工作樹建好之後');
});
test('進行中標籤已經在議題上時不重複貼', async (t) => {
const stub = await withStub(t, {}, {
assignees: [ME],
labels: ['進行中'],
repoLabels: ['進行中'],
});
const { json } = await run([], stub);
assert.equal(json.data.已認領過, true);
assert.deepEqual(json.data.labels, ['進行中']);
assert.equal(
writes(stub).some((r) => r.path.endsWith('/labels')),
false,
'已經貼著的標籤不必再貼一次',
);
});
// ── 標籤:本 plugin 不自動建立標籤 ─────────────────────────────────
test('repo 上沒有進行中標籤時擋在寫入之前,並指出該去建哪一個', async (t) => {
const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent'] });
const { code, json } = await run([], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'LABEL_NOT_FOUND');
assert.match(json.error.message, /進行中/);
assert.deepEqual(writes(stub), [], '標籤缺了就整件事不做,不要只設一半的鎖');
});
// ── 錯誤 ───────────────────────────────────────────────────────────
test('議題不存在時回傳可區分的錯誤碼', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 404, body: { message: 'not found' } },
}, { repoLabels: ['進行中'] });
const { json } = await run([], stub);
assert.equal(json.error.code, 'ISSUE_NOT_FOUND');
});
test('--index 不是正整數時擋在打 Gitea 之前', async (t) => {
const stub = await withStub(t, {}, { repoLabels: ['進行中'] });
const { json } = await runScript('claim.js', ['--repo', REPO, '--index', '0'], {
env: envFor(stub),
});
assert.equal(json.error.code, 'BAD_INDEX');
assert.equal(stub.requests.length, 0);
});
// ── --dry-run ─────────────────────────────────────────────────────
test('--dry-run 印出將發出的寫入,但一個字都不寫進去', async (t) => {
const stub = await withStub(t, {}, { repoLabels: ['進行中'] });
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}`),
[
`PATCH /repos/${REPO}/issues/${INDEX}`,
`POST /repos/${REPO}/issues/${INDEX}/labels`,
],
);
assert.deepEqual(writes(stub), [], '預覽不得真的寫入');
});
test('--dry-run 會先讀現況:預覽出來的是這一顆實際的處境', async (t) => {
// 手寫一份固定的清單很容易跟實作走鐘,而且說不出「這顆已經是你的了」這種事
const stub = await withStub(t, {}, { repoLabels: ['進行中'] });
await run(['--dry-run'], stub);
const reads = stub.requests.filter((r) => r.method === 'GET').map((r) => r.path);
assert.ok(reads.includes('/api/v1/user'));
assert.ok(reads.includes(`/api/v1/repos/${REPO}/issues/${INDEX}`));
assert.ok(reads.includes('/api/v1/user/stopwatches'));
assert.ok(reads.includes(`/api/v1/repos/${REPO}/labels`));
});
test('--dry-run 略過已經做好的部分:鎖都在了就什麼都不必寫', async (t) => {
const stub = await withStub(t, {}, {
assignees: [ME],
labels: ['進行中'],
repoLabels: ['進行中'],
});
const { json } = await run(['--dry-run'], stub);
assert.deepEqual(
json.data.requests,
[],
'assignee 與標籤都已經到位,中斷重跑就是走到這裡;手寫一份固定的清單會謊報',
);
});
test('--dry-run 在鎖擋得住的情況下照樣擋,這才是預覽的用處', async (t) => {
const stub = await withStub(t, {}, { assignees: ['someone-else'], repoLabels: ['進行中'] });
const { code, json } = await run(['--dry-run'], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'CLAIMED_BY_OTHER');
});
test('--dry-run 遇到缺標籤一樣報錯,不會等到實跑才發現', async (t) => {
const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent'] });
const { json } = await run(['--dry-run'], stub);
assert.equal(json.error.code, 'LABEL_NOT_FOUND');
});
-220
View File
@@ -1,220 +0,0 @@
/**
* 委派:判準正本、正本上的標記,以及兩者之間那條雙向斷言。
*
* 判準與標記分住兩個檔案,而它們講的是同一件事。沒有雙向斷言,兩邊會慢慢漂開——
* 而漂開的時候不會有任何東西報錯:正本上多標一步不會壞,判準表少列一項也不會壞,
* 只是下一個讀的人會以為自己讀到的是全部。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
DELEGATABLE,
PLATFORM_SPECIFIC,
promptSteps,
readPrompt,
readReference,
} from './helpers/prompt-doc.js';
const PROMPTS = ['sdlc-plan', 'sdlc-analyze', 'sdlc-feat', 'sdlc-fix', 'sdlc-sync', 'sdlc-report'];
const reference = readReference('delegation');
/** 帶標記的三份正本;另外三份目前沒有可委派的步驟 */
const 有標記的正本 = ['sdlc-plan', 'sdlc-analyze', 'sdlc-feat'];
/** `正本/步驟` 這種好讀的鍵,比對失敗時看得出差在哪一步 */
const 鍵 = ({ prompt, name }) => `${prompt}/${name}`;
/** delegation.md 那張表列出來的步驟 */
const 表上的 = () =>
[...reference.matchAll(/^\| `(sdlc-[a-z]+)` \| (.+?) \| (.+?) \|$/gm)].map((m) => ({
prompt: m[1],
name: m[2].trim(),
範圍: m[3].trim(),
}));
/** 正本上實際被標記的步驟 */
const 正本上的 = () =>
PROMPTS.flatMap((prompt) =>
promptSteps(readPrompt(prompt))
.filter((step) => step.marked)
.map((step) => ({ prompt, name: step.name, body: step.body })),
);
// ── 判準正本 ───────────────────────────────────────────────────────
test('delegation.md 的開頭形狀與既有規則正本一致', () => {
assert.equal(reference.startsWith('# '), true, '規則正本一律以 H1 起頭,不放 frontmatter');
assert.match(reference.split('\n')[0], /委派/);
});
/** 判準那一節的四條,各自含標題與理由 */
function 判準逐條() {
const 節 = reference.slice(reference.indexOf('## 判準'), reference.indexOf('## 怎麼委派'));
return 節.split(/^(?=\d+\. \*\*)/m).filter((one) => /^\d+\. \*\*/.test(one));
}
test('四條判準逐條載明,一條不多一條不少', () => {
const 判準 = reference.slice(reference.indexOf('## 判準'), reference.indexOf('## 怎麼委派'));
const 條 = 判準逐條().map((one) => /^\d+\. \*\*(.+?)\*\*/.exec(one)[1]);
assert.equal(條.length, 4, `判準應為四條,目前 ${條.length} 條:${條.join('、')}`);
assert.match(判準, /可驗證的成品/);
assert.match(判準, /不會詢問使用者/);
assert.match(判準, /失敗能被呼叫端偵測/);
assert.match(判準, /不直接寫入 Gitea 或 git/);
});
test('第二條與第四條各自標明是硬排除,並各自寫出理由', () => {
// 數「硬排除」出現幾次的話,別處多提一句就會失敗;要問的是「那兩條上面有沒有」
const [, 二, , 四] = 判準逐條();
assert.match(二, /硬排除/, '第二條是硬排除,不是建議');
assert.match(二, /子代理問不到使用者/, '沒寫理由的話,下一個人會把它當成建議而繞過去');
assert.match(四, /硬排除/, '第四條是硬排除,不是建議');
assert.match(四, /失敗沒有人看著/, '理由不是子代理做不好,要寫清楚,否則會被當成不信任');
});
// ── 雙向斷言 ───────────────────────────────────────────────────────
test('正本上被標記的集合,等於 delegation.md 列出的集合', () => {
const 表 = 表上的().map(鍵).sort();
const 正本 = 正本上的().map(鍵).sort();
assert.deepEqual(正本, 表, '改一邊就要改另一邊,否則兩份說法會漂開');
});
// 這一條與上一條刻意重複:雙向斷言只保證兩邊一致,兩邊一起改就一起漂走。
// 把議題點名的那六個逐字釘在這裡,改動才需要有人明確地改掉這份清單。
test('被標記的正好是議題點名的那六個', () => {
assert.deepEqual(正本上的().map(鍵).sort(), [
'sdlc-analyze/算出截止日',
'sdlc-analyze/對四份清單列出疑點',
'sdlc-analyze/產生分析版的圖解總覽',
'sdlc-feat/把議題標題翻成英文',
'sdlc-feat/分批提交',
'sdlc-plan/產生圖解版總覽',
].sort());
});
// ── 判準第二條的迴歸保護 ───────────────────────────────────────────
/** 會問使用者的步驟。子代理問不到人,這些永遠不該被標上可委派。 */
const 會問使用者 = [
['sdlc-plan', '逐項詢問'],
['sdlc-analyze', '逐題問到共識'],
['sdlc-feat', '問來源分支'],
['sdlc-feat', '認出語言,讀規則正本'],
];
test('點名的問到共識類步驟一律未被標記', () => {
for (const [prompt, name] of 會問使用者) {
const step = promptSteps(readPrompt(prompt)).find((one) => one.name === name);
assert.ok(step, `${prompt} 少了「${name}」這一步;步驟改名的話這份清單要跟著改`);
assert.equal(step.marked, false, `${prompt}/${name} 會問使用者,判準第二條硬排除`);
}
});
test('任何看得出在問使用者的步驟都沒有被標記', () => {
// 只認明確的提問語,不認「不要拿去問使用者」那種否定句——那句正好出現在可委派的步驟裡
const 提問語 = /一次問一題|停下來問|等使用者回答|問到共識|問過使用者/;
let 掃過 = 0;
let 認出 = 0;
for (const prompt of PROMPTS) {
for (const step of promptSteps(readPrompt(prompt))) {
掃過 += 1;
if (!提問語.test(step.body)) continue;
認出 += 1;
assert.equal(step.marked, false, `${prompt}/${step.name} 在問使用者,不該標可委派`);
}
}
// 兩道自我檢查:這種掃描最常見的壞法是「一條都沒掃到」,而那時它照樣是綠的。
// 目前有編號步驟的是 plan/analyze/feat/report 四份,sdlc-fix 與 sdlc-sync 沒有編號步驟
assert.ok(掃過 >= 40, `只掃到 ${掃過} 個步驟,正本的步驟標題格式可能變了`);
assert.ok(認出 >= 4, `提問語一個步驟都沒認出來(${認出}),這道保護已經形同虛設`);
});
// ── 判準第四條:不直接寫入 ─────────────────────────────────────────
/** 會寫入 Gitea 或 git 的腳本。被標記的步驟碰到它們,就要寫明哪一半不委派。 */
const 寫入型 = [
'issue-create',
'issue-update',
'issue-link',
'project-add',
'pr-create',
'claim.js',
'branch-prep',
'worktree-ensure',
'worktree-remove',
'timer.js',
'time-log.js',
'commit-split',
];
test('被標記的步驟若碰得到寫入,就要寫明哪一半不委派', () => {
for (const step of 正本上的()) {
const 碰到 = 寫入型.filter((script) => step.body.includes(script));
if (碰到.length === 0) continue;
assert.match(
step.body,
/不委派/,
`${鍵(step)} 用到 ${碰到.join('、')},要寫明那一半留給主流程`,
);
}
});
test('三步部分委派的範圍,判準表上也說得出來', () => {
const 部分 = 表上的().filter((one) => one.範圍 !== '全步');
assert.equal(部分.length, 3, '兩份圖解總覽與分批提交是部分委派');
for (const one of 部分) {
assert.match(one.範圍, /不委派/, `${鍵(one)} 的範圍要說出哪一半不委派`);
}
});
test('另外三份正本一個標記都沒有,與判準正本結尾那句話一致', () => {
for (const prompt of PROMPTS.filter((one) => !有標記的正本.includes(one))) {
assert.equal(
readPrompt(prompt).includes(DELEGATABLE),
false,
`${prompt} 出現了標記,但 delegation.md 結尾說它沒有可委派的步驟`,
);
}
assert.match(reference, /`sdlc-sync`、`sdlc-fix` 與 `sdlc-report` 目前沒有可委派的步驟/);
});
// ── 平台中立 ───────────────────────────────────────────────────────
test('判準正本與標記都不指名任何平台的工具', () => {
for (const token of PLATFORM_SPECIFIC) {
assert.equal(reference.includes(token), false, `delegation.md 不該出現平台專屬字樣:${token}`);
}
// 子代理是平台專屬能力,正本只能以能力描述帶過
assert.match(reference, /能力描述/);
assert.match(reference, /不能就自己做/);
});
test('三份帶標記的正本各自說明了這個後綴是什麼意思,並指名判準正本', () => {
for (const prompt of 有標記的正本) {
const text = readPrompt(prompt);
assert.match(text, new RegExp(`## ${DELEGATABLE}的意思`), `${prompt} 要解釋這個後綴`);
assert.match(text, /references\/delegation\.md/, `${prompt} 要指名判準正本,不要把判準抄過去`);
assert.match(text, /不能就自己做/, `${prompt} 要寫成能力描述,讓不支援的平台自然降級`);
}
});
test('標記是標題後綴,不用 emoji 也不用 HTML 註解', () => {
for (const prompt of PROMPTS) {
const text = readPrompt(prompt);
assert.equal(text.includes('<!--'), false, `${prompt}:HTML 註解模型讀不穩,不拿它當標記`);
// 標記只出現在標題後綴與那一節的說明裡,不會單獨浮在內文中間
for (const line of text.split('\n')) {
if (!line.includes(DELEGATABLE)) continue;
// 三種合法位置,寫死成互斥的三條。曾經第二條寫成 /^#{2,3} /,把第一條整個
// 吃掉了——那時候「### 工作包議題 〔可委派〕」這種沒編號的標題也會通過
const 合法 =
new RegExp(`^### \\d+\\. .+ ${DELEGATABLE}$`).test(line) ||
line === `## ${DELEGATABLE}的意思` ||
line.includes(`\`${DELEGATABLE}\``);
assert.ok(合法, `${prompt}:標記出現在不該出現的位置:${line}`);
}
}
});
-24
View File
@@ -303,27 +303,3 @@ test('--dry-run 遇到同名議題時,如實顯示實跑會是 no-op', async (
assert.equal(json.data.existing.number, 9);
});
// ── 前置檢查仍然生效 ───────────────────────────────────────────────
test('寫入型腳本一樣跑前置檢查:時間追蹤沒開就中止', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}`]: {
status: 200,
body: {
has_issues: true,
permissions: { admin: true, push: true, pull: true },
internal_tracker: { enable_time_tracker: false },
},
},
});
const { code, json } = await runScript(
'issue-create.js',
['--repo', REPO, '--title', TITLE, '--body-file', writeBody()],
{ env: envFor(stub) },
);
assert.equal(code, 1);
assert.equal(json.error.code, 'TIME_TRACKER_OFF');
assert.equal(stub.requests.some((r) => r.method === 'POST'), false);
});
-52
View File
@@ -1,52 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { renderOverview, validateOverview } from '../scripts/overview-render.js';
import { repoRoot } from './helpers/run-script.js';
const base = {
schemaVersion: 1,
source: { requirement: { repo: 'owner/repo', index: 7 } },
requirement: {
title: '建立需求總覽',
summary: '把複雜需求整理成可理解的交接資料。',
goals: ['可追蹤工作包', '可離線檢視'],
nonGoals: [],
diagrams: [{ kind: 'flowchart', direction: 'LR', title: '輸入流程', nodes: [{ id: 'a', label: '輸入' }, { id: 'b', label: '輸出' }], edges: [{ from: 'a', to: 'b', label: '轉換' }] }],
formulas: [{ title: '估算', latex: 'a+b', description: '簡單公式' }],
},
workPackages: [{
title: '建立抽取契約',
type: 'implementation',
description: '提供可重複的資料抽取。',
scope: '只處理抽取。',
repos: ['owner/repo'],
depends: [],
todos: [{ text: '建立函式', done: true }],
acceptance: ['輸出固定'],
issue: 'https://gitea.example/owner/repo/issues/8',
handoff: false,
}],
overview: { generatedAt: '2026-09-18T00:00:00Z' },
};
test('renderer validates and renders self-contained HTML', () => {
const html = renderOverview(base);
assert.match(html, /<!doctype html>/i);
assert.match(html, /<svg/);
assert.match(html, /建立抽取契約/);
assert.match(html, /a\+b/);
assert.doesNotMatch(html, /mermaid/);
assert.doesNotMatch(html, /cdn\.jsdelivr/);
});
test('renderer rejects unsupported schema versions and graph kinds', () => {
assert.throws(() => validateOverview({ ...base, schemaVersion: 2 }), (error) => error.code === 'SCHEMA_UNSUPPORTED');
assert.throws(() => validateOverview({ ...base, requirement: { ...base.requirement, diagrams: [{ kind: 'unknown', nodes: [], edges: [] }] } }), (error) => error.code === 'DIAGRAM_UNSUPPORTED');
});
test('renderer script is shipped and template is intentionally removed', () => {
assert.equal(readFileSync(join(repoRoot, 'scripts', 'overview-render.js'), 'utf8').includes('overview'), true);
assert.throws(() => readFileSync(join(repoRoot, 'templates', 'overview-artifact.html'), 'utf8'));
});
-36
View File
@@ -1,36 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { runScript, repoRoot, tmpRoot } from './helpers/run-script.js';
const input = join(tmpRoot, 'capture-input.json');
const html = join(tmpRoot, 'capture.html');
const svg = join(tmpRoot, 'capture.svg');
const manifest = join(tmpRoot, 'capture-manifest.json');
const document = {
schemaVersion: 1,
source: { requirement: { repo: 'owner/repo', index: 7 } },
requirement: { title: 'Capture', summary: 'Fallback', goals: [], nonGoals: [], diagrams: [{ kind: 'flowchart', nodes: [{ id: 'a', label: 'A' }], edges: [] }] },
workPackages: [],
overview: { generatedAt: '2026-09-18T00:00:00Z' },
};
import { mkdirSync, writeFileSync } from 'node:fs';
mkdirSync(tmpRoot, { recursive: true });
writeFileSync(input, `${JSON.stringify(document)}\n`);
test('renderer emits SVG fallback alongside HTML', async () => {
const result = await runScript('overview-render.js', ['--input', input, '--output', html, '--svg', svg, '--manifest', manifest]);
assert.equal(result.code, 0, JSON.stringify(result.json));
assert.equal(existsSync(svg), true);
assert.match(readFileSync(svg, 'utf8'), /^<svg/);
assert.equal(result.json.data.svg, svg);
});
test('capture reports missing backend instead of claiming success', async () => {
const result = await runScript('overview-capture.js', ['--backend', 'svg', '--svg', svg, '--output', join(tmpRoot, 'capture.png')]);
if (result.code === 0) assert.equal(result.json.data.backend, 'svg');
else assert.equal(result.json.error.code, 'BINARY_NOT_FOUND');
});
-454
View File
@@ -1,454 +0,0 @@
/**
* 開立 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/);
});
/**
* 「沒有碼錶在跑」這件事,Gitea 不只用一種狀態碼回。
* 站台版本不同回法就不同,而這時 PR 已經建立——把整件事報成失敗,使用者會以為 PR
* 沒開成而重跑一次。
*/
const NO_STOPWATCH = [
{ status: 500, message: 'cannot stop a non existent stopwatch' },
{ status: 409, message: 'cannot stop a non-existent stopwatch' },
];
for (const { status, message } of NO_STOPWATCH) {
test(`錶本來就沒在跑時不算失敗(HTTP ${status}):PR 已經開出去了`, async (t) => {
const stub = await withStub(t, {
[`POST /api/v1/repos/${ISSUE_REPO}/issues/${INDEX}/stopwatch/stop`]: {
status,
body: { message },
},
});
const file = bodyFile(`nowatch-${status}`, 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 ?? '', /碼錶/);
assert.ok(json.data.url, 'PR 的網址照常回報:它真的開出去了');
});
}
test('訊息對不上碼錶的 409 照常拋出,不被一起吞掉', async (t) => {
// 只有「沒有碼錶在跑」那一種能被當成不算失敗;其餘的 409 是真的有問題
const stub = await withStub(t, {
[`POST /api/v1/repos/${ISSUE_REPO}/issues/${INDEX}/stopwatch/stop`]: {
status: 409,
body: { message: 'issue is locked' },
},
});
const file = bodyFile('locked', BODY);
const { code, json } = await runFull(file, stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'HTTP_ERROR');
assert.match(json.error.message, /409/);
});
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, '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');
});
+6 -226
View File
@@ -1,231 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { runScript, pathWithOnly } from './helpers/run-script.js';
import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js';
import { runScript } from './helpers/run-script.js';
const REPO = 'plugins/tea-sdlc';
async function withStub(t, overrides = {}) {
return withStubGitea(t, healthyRoutes(REPO, overrides));
}
// ── 第一層:執行環境 ────────────────────────────────────────────────
test('第一層:PATH 上沒有 git 時中止,錯誤訊息指名 git', async (t) => {
const stub = await withStub(t);
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], {
env: envFor(stub),
path: pathWithOnly(['tea']),
test('一般腳本不再依賴時間追蹤設定', async () => {
const result = await runScript('report.js', ['--repo', 'plugins/tea-sdlc', '--week'], {
env: { TEA_CONFIG: '/tmp/nonexistent-tea-config' },
});
assert.equal(code, 1);
assert.equal(json.error.code, 'ENV_MISSING');
assert.match(json.error.message, /git/);
assert.equal(stub.requests.length, 0, '環境不通過就不該發請求');
});
test('第一層:PATH 上沒有 tea 時中止,錯誤訊息指名 tea 並給出安裝指引', async (t) => {
const stub = await withStub(t);
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], {
env: envFor(stub),
path: pathWithOnly(['git']),
});
assert.equal(code, 1);
assert.equal(json.error.code, 'ENV_MISSING');
assert.match(json.error.message, /tea/);
});
// ── 第二層:Gitea 登入 ─────────────────────────────────────────────
test('第二層:token 無效時中止,訊息指向 tea login', async (t) => {
const stub = await withStub(t, {
'GET /api/v1/user': { status: 401, body: { message: 'unauthorized' } },
});
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
assert.equal(code, 1);
assert.equal(json.error.code, 'LOGIN_INVALID');
assert.match(json.error.message, /tea login/);
});
test('第二層:找不到任何登入資訊時中止', async (t) => {
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], {
env: { TEA_SDLC_API_BASE: '', TEA_SDLC_TOKEN: '', TEA_SDLC_CONFIG: '/nonexistent/tea.yml' },
});
assert.equal(code, 1);
assert.equal(json.error.code, 'LOGIN_INVALID');
});
// ── 第三層:issues unit 寫入權 ─────────────────────────────────────
test('第三層:repo 不存在或讀不到時,錯誤碼與權限問題分得開', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}`]: { status: 404, body: { message: 'not found' } },
});
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
assert.equal(code, 1);
assert.equal(json.error.code, 'REPO_NOT_FOUND');
});
test('第三層:repo 關閉議題功能時中止,訊息指出開啟位置', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}`]: {
status: 200,
body: {
has_issues: false,
permissions: { admin: false, push: true, pull: true },
internal_tracker: { enable_time_tracker: true },
},
},
});
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
assert.equal(code, 1);
assert.equal(json.error.code, 'ISSUES_UNIT_OFF');
assert.match(json.error.message, /Settings/);
});
test('第三層:對 issues unit 實測寫入權,被擋下時中止', async (t) => {
const stub = await withStub(t, {
[`PATCH /api/v1/repos/${REPO}/issues/0`]: { status: 403, body: { message: 'forbidden' } },
});
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
assert.equal(code, 1);
assert.equal(json.error.code, 'NO_ISSUE_WRITE');
assert.match(json.error.message, /issues/i);
});
test('第三層:不能只看 permissions.push——push 為真但 issues unit 被擋,仍須失敗', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}`]: {
status: 200,
body: {
has_issues: true,
// team 的 unit 權限可獨立於 repo 的 push 權限,所以這裡是真的也不算數
permissions: { admin: false, push: true, pull: true },
internal_tracker: { enable_time_tracker: true },
},
},
[`PATCH /api/v1/repos/${REPO}/issues/0`]: { status: 403, body: { message: 'forbidden' } },
});
const { json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
assert.equal(json.error.code, 'NO_ISSUE_WRITE');
});
test('第三層:探針打在不存在的議題 index 0,確保無副作用', async (t) => {
const stub = await withStub(t);
await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
const probes = stub.requests.filter((r) => r.method === 'PATCH');
assert.equal(probes.length, 1);
assert.equal(probes[0].path, `/api/v1/repos/${REPO}/issues/0`);
assert.deepEqual(probes[0].body, {}, '探針不得挾帶任何要寫入的欄位');
});
// ── 第四層:時間追蹤 ───────────────────────────────────────────────
test('第四層:時間追蹤未開啟時中止,訊息指出 Enable Time Tracker 的位置', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}`]: {
status: 200,
body: {
has_issues: true,
permissions: { admin: false, push: true, pull: true },
internal_tracker: { enable_time_tracker: false },
},
},
});
const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
assert.equal(code, 1);
assert.equal(json.error.code, 'TIME_TRACKER_OFF');
assert.match(json.error.message, /Settings → Advanced Settings → Enable Time Tracker/);
});
// ── 四層之間 ───────────────────────────────────────────────────────
test('四層的錯誤碼兩兩相異,呼叫端分得出是哪一層壞了', async (t) => {
const cases = [
{
code: 'ENV_MISSING',
run: async () => {
const stub = await withStub(t);
return runScript('labels-list.js', ['--repo', REPO], {
env: envFor(stub),
path: pathWithOnly(['git']),
});
},
},
{
code: 'LOGIN_INVALID',
run: async () => {
const stub = await withStub(t, {
'GET /api/v1/user': { status: 401, body: {} },
});
return runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
},
},
{
code: 'NO_ISSUE_WRITE',
run: async () => {
const stub = await withStub(t, {
[`PATCH /api/v1/repos/${REPO}/issues/0`]: { status: 403, body: {} },
});
return runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
},
},
{
code: 'TIME_TRACKER_OFF',
run: async () => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}`]: {
status: 200,
body: {
has_issues: true,
permissions: { admin: true, push: true, pull: true },
internal_tracker: { enable_time_tracker: false },
},
},
});
return runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
},
},
];
const seen = [];
for (const { code, run } of cases) {
const { json } = await run();
assert.equal(json.error.code, code);
seen.push(json.error.code);
}
assert.equal(new Set(seen).size, seen.length);
});
test('前一層失敗就停手,不會再往下打', async (t) => {
const stub = await withStub(t, {
'GET /api/v1/user': { status: 401, body: {} },
});
await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
assert.deepEqual(
stub.requests.map((r) => r.path),
['/api/v1/user'],
'登入檢查沒過就不該再查 repo 或標籤',
);
assert.equal(result.code, 1);
assert.equal(result.json.error.code, 'REPORT_UNAVAILABLE');
});
+11 -508
View File
@@ -1,514 +1,17 @@
/**
* 工時報表:期間切法、週次歸屬與估算落差。
*
* 這支腳本的難處不在取資料,而在「哪一筆工時算在哪一週、哪一週算在哪個月」。
* 跨月、跨年、當月有五個週五三種邊界各自都會讓人算錯,所以逐一釘住。
*
* 時區在測試裡固定為 Asia/Taipei:週界是以人在的時區切的,不釘住時區就等於沒釘住答案。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { runScript } from './helpers/run-script.js';
import { healthyRoutes, stubEnv, withStubGitea } from './helpers/stub-gitea.js';
const REPO = 'plugins/tea-sdlc';
const TZ = 'Asia/Taipei';
/** 一顆工作包議題;估算寫在「關聯」段落,那是 issue-update 唯一寫得進去的地方 */
function issue(number, { title = `工作包 ${number}`, days = null, repo = REPO } = {}) {
const 關聯 = days === null ? '需求議題:#1' : `需求議題:#1\n估算人天:${days}`;
return {
number,
title,
html_url: `https://gitea.example/${repo}/issues/${number}`,
body: `## 這個工作包在做什麼\n\n做一件事\n\n## 關聯\n\n${關聯}\n`,
repository: { full_name: repo },
};
}
/** 一筆工時。created 寫成不帶時區的本地時刻,讀起來就是「那天的幾點」 */
let nextId = 1;
function time(created, hours, issueObject) {
return {
id: nextId++,
created: new Date(`${created}T10:00:00+08:00`).toISOString(),
time: Math.round(hours * 3600),
user_name: 'tester',
issue: issueObject,
};
}
/** 啟一台假 Gitea,/user/times 回傳指定的工時清單 */
async function withTimes(t, times, overrides = {}) {
return withStubGitea(
t,
healthyRoutes(REPO, {
'GET /api/v1/user/times': { status: 200, body: times },
...overrides,
}),
);
}
const run = (stub, args) =>
runScript('report.js', ['--repo', REPO, ...args], { env: { ...stubEnv(stub), TZ } });
/** 依名稱取出分段小計的秒數 */
const segmentSeconds = (json) =>
Object.fromEntries(json.data.分段.map((s) => [s.名稱, s.實際秒]));
// ── 期間:本週 ─────────────────────────────────────────────────────
test('預設為本週:起於本週一、迄於今日', async (t) => {
const stub = await withTimes(t, []);
const { code, json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(code, 0);
assert.equal(json.data.期間.類型, 'week');
assert.equal(json.data.期間.起, '2026-09-14');
assert.equal(json.data.期間.迄, '2026-09-17');
});
test('今天就是週一時,本週只有今天這一天', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--today', '2026-09-14']);
assert.equal(json.data.期間.起, '2026-09-14');
assert.equal(json.data.期間.迄, '2026-09-14');
});
test('今天是週日時仍屬同一週,不跳到下週一', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--today', '2026-09-20']);
assert.equal(json.data.期間.起, '2026-09-14');
assert.equal(json.data.期間.迄, '2026-09-20');
});
test('只計入期間內的工時,期間外的一秒都不算', async (t) => {
const wp = issue(12);
const stub = await withTimes(t, [
time('2026-09-13', 8, wp), // 上週日
time('2026-09-14', 2, wp), // 本週一
time('2026-09-17', 1.5, wp), // 今天
time('2026-09-18', 4, wp), // 今天之後
]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.總計.實際秒, 3.5 * 3600);
});
test('週報沒有分段小計:一週之內沒有更小的段落', async (t) => {
const stub = await withTimes(t, [time('2026-09-15', 1, issue(12))]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.deepEqual(json.data.分段, []);
});
// ── 期間:月報與 W1–W5 ────────────────────────────────────────────
test('月報依「該週週五所屬月份」歸屬:月初跨月的那一週算進本月', async (t) => {
// 2026-01 的第一個週五是 01-02,那一週的週一落在 2025-12-29
const stub = await withTimes(t, [time('2025-12-29', 3, issue(12))]);
const { json } = await run(stub, ['--month', '2026-01']);
assert.equal(json.data.期間.起, '2025-12-29');
assert.equal(json.data.總計.實際秒, 3 * 3600);
assert.equal(segmentSeconds(json).W1, 3 * 3600);
});
test('月報依「該週週五所屬月份」歸屬:月末跨月的那一週算進下個月', async (t) => {
// 2026-02-01 是週日,它那一週的週五是 01-30,所以歸 2026-01 而非 2026-02
const stub = await withTimes(t, [time('2026-02-01', 5, issue(12))]);
const january = await run(stub, ['--month', '2026-01']);
const february = await run(stub, ['--month', '2026-02']);
assert.equal(january.json.data.總計.實際秒, 5 * 3600);
assert.equal(february.json.data.總計.實際秒, 0, '同一筆工時不得被兩個月重複計算');
});
test('W 編號為該週五是當月第幾個週五,有五個週五的月份排到 W5', async (t) => {
// 2026-01 的週五:02、09、16、23、30
const wp = issue(12);
const stub = await withTimes(t, [
time('2026-01-02', 1, wp),
time('2026-01-09', 2, wp),
time('2026-01-16', 3, wp),
time('2026-01-23', 4, wp),
time('2026-01-30', 5, wp),
]);
const { json } = await run(stub, ['--month', '2026-01']);
assert.deepEqual(json.data.分段.map((s) => s.名稱), ['W1', 'W2', 'W3', 'W4', 'W5']);
assert.deepEqual(segmentSeconds(json), {
W1: 1 * 3600,
W2: 2 * 3600,
W3: 3 * 3600,
W4: 4 * 3600,
W5: 5 * 3600,
test('週報月報年報固定回傳 REPORT_UNAVAILABLE', async () => {
const result = await runScript('report.js', ['--repo', 'plugins/tea-sdlc', '--week'], {
env: { TEA_CONFIG: '/tmp/nonexistent-tea-config' },
});
assert.equal(result.code, 1);
assert.deepEqual(result.json, {
ok: false,
error: {
code: 'REPORT_UNAVAILABLE',
message: '週報、月報、年報目前不可用;時間追蹤功能已移除。',
},
});
});
test('只有四個週五的月份就只有 W1–W4,不硬湊出空的 W5', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--month', '2026-02']);
assert.deepEqual(json.data.分段.map((s) => s.名稱), ['W1', 'W2', 'W3', 'W4']);
});
test('沒有工時的週次仍然列出來,小計為零', async (t) => {
const stub = await withTimes(t, [time('2026-02-06', 1, issue(12))]);
const { json } = await run(stub, ['--month', '2026-02']);
assert.deepEqual(segmentSeconds(json), { W1: 3600, W2: 0, W3: 0, W4: 0 });
});
test('每個週次都標出自己的起迄,週一到週日', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--month', '2026-01']);
assert.deepEqual(json.data.分段[0], {
名稱: 'W1',
起: '2025-12-29',
迄: '2026-01-04',
實際秒: 0,
實際工時: '0h 00m',
});
});
// ── 期間:年報與跨年 ──────────────────────────────────────────────
test('年報以月份分段小計', async (t) => {
const stub = await withTimes(t, [time('2026-03-04', 2, issue(12))]);
const { json } = await run(stub, ['--year', '2026']);
assert.equal(json.data.分段.length, 12);
assert.deepEqual(json.data.分段.map((s) => s.名稱).slice(0, 3), ['2026-01', '2026-02', '2026-03']);
assert.equal(segmentSeconds(json)['2026-03'], 2 * 3600);
});
test('跨年的那一週依週五歸屬:12/29 的工時算進下一年', async (t) => {
// 2025-12-29 是週一,它那一週的週五是 2026-01-02
const stub = await withTimes(t, [time('2025-12-29', 6, issue(12))]);
const y2025 = await run(stub, ['--year', '2025']);
const y2026 = await run(stub, ['--year', '2026']);
assert.equal(y2025.json.data.總計.實際秒, 0);
assert.equal(y2026.json.data.總計.實際秒, 6 * 3600);
assert.equal(segmentSeconds(y2026.json)['2026-01'], 6 * 3600);
});
test('年報的起迄由第一個與最後一個週五所在的週決定', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--year', '2026']);
// 首個週五 2026-01-02 的週一是 2025-12-29;末個週五 2026-12-25 的週日是 2026-12-27
assert.equal(json.data.期間.起, '2025-12-29');
assert.equal(json.data.期間.迄, '2026-12-27');
});
// ── 估算落差 ───────────────────────────────────────────────────────
test('估算取自議題「關聯」段落的估算人天,落差為實際減估算', async (t) => {
const stub = await withTimes(t, [time('2026-09-15', 20, issue(12, { days: 2 }))]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.總計.估算人天, 2);
assert.equal(json.data.總計.落差秒, (20 - 16) * 3600, '2 人天 × 8 小時 = 16 小時');
assert.equal(json.data.總計.落差工時, '+4h 00m');
});
test('實際少於估算時落差為負', async (t) => {
const stub = await withTimes(t, [time('2026-09-15', 6, issue(12, { days: 1 }))]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.總計.落差秒, -2 * 3600);
assert.equal(json.data.總計.落差工時, '-2h 00m');
});
test('--day-hours 換掉一人天等於幾小時的假設', async (t) => {
const stub = await withTimes(t, [time('2026-09-15', 7, issue(12, { days: 1 }))]);
const { json } = await run(stub, ['--today', '2026-09-17', '--day-hours', '7']);
assert.equal(json.data.每日工時, 7);
assert.equal(json.data.總計.落差秒, 0);
});
test('議題沒寫估算時落差為 null,不當成零', async (t) => {
const stub = await withTimes(t, [time('2026-09-15', 3, issue(12))]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.議題[0].估算人天, null);
assert.equal(json.data.議題[0].落差秒, null);
assert.equal(json.data.總計.估算人天, 0, '總計只加得起來有估算的那些');
assert.equal(json.data.總計.落差秒, null, '一顆估算都沒有時,沒有東西可以比');
});
test('總計的落差只拿有估算的議題來比,沒估算的工時不算成超出估算', async (t) => {
const stub = await withTimes(t, [
time('2026-09-15', 6, issue(12, { days: 1 })), // 估 8 小時、實際 6 小時
time('2026-09-16', 30, issue(13)), // 沒估算,30 小時
]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.總計.實際秒, 36 * 3600, '實際總計仍然是全部');
assert.equal(json.data.總計.已估實際秒, 6 * 3600, '落差的分母只有有估算的那顆');
assert.equal(json.data.總計.落差秒, -2 * 3600);
assert.notEqual(json.data.總計.落差秒, 28 * 3600, '拿全部實際去比部分估算會灌出假的超支');
});
// ── 逐議題明細 ─────────────────────────────────────────────────────
test('依議題彙總,帶上標題與網址,工時多的排前面', async (t) => {
const stub = await withTimes(t, [
time('2026-09-14', 1, issue(12, { title: '建立抽取契約', days: 3 })),
time('2026-09-15', 4, issue(13, { title: '補上前置檢查' })),
time('2026-09-16', 2, issue(12, { title: '建立抽取契約', days: 3 })),
]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.deepEqual(json.data.議題, [
{
index: 13,
title: '補上前置檢查',
url: 'https://gitea.example/plugins/tea-sdlc/issues/13',
實際秒: 4 * 3600,
實際工時: '4h 00m',
估算人天: null,
落差秒: null,
落差工時: null,
},
{
index: 12,
title: '建立抽取契約',
url: 'https://gitea.example/plugins/tea-sdlc/issues/12',
實際秒: 3 * 3600,
實際工時: '3h 00m',
估算人天: 3,
落差秒: -21 * 3600,
落差工時: '-21h 00m',
},
]);
});
test('工時以時分呈現,秒數不進位成假的精確', async (t) => {
const stub = await withTimes(t, [time('2026-09-15', 1.51, issue(12))]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.總計.實際工時, '1h 30m');
});
// ── 範圍:只算指定 repo 的工時 ────────────────────────────────────
test('別的 repo 的工時不算進來', async (t) => {
const stub = await withTimes(t, [
time('2026-09-15', 2, issue(12)),
time('2026-09-15', 8, issue(4, { repo: 'plugins/other' })),
]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.總計.實際秒, 2 * 3600);
assert.deepEqual(json.data.議題.map((i) => i.index), [12]);
});
test('查不到議題資訊的工時不默默消失,回報則數', async (t) => {
const stub = await withTimes(t, [
time('2026-09-15', 2, issue(12)),
{ id: 99, created: '2026-09-15T02:00:00Z', time: 3600, user_name: 'tester' },
]);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.略過, 1, '無議題資訊時整份報表會憑空變空,數字要留在輸出裡');
assert.equal(json.data.總計.實際秒, 2 * 3600);
});
test('沒有任何工時時回空報表,不是錯誤', async (t) => {
const stub = await withTimes(t, []);
const { code, json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(code, 0);
assert.equal(json.ok, true);
assert.equal(json.data.總計.實際秒, 0);
assert.deepEqual(json.data.議題, []);
});
// ── 取資料的方式 ───────────────────────────────────────────────────
test('工時逐頁讀完,不是只讀第一頁', async (t) => {
const wp = issue(12);
const first = Array.from({ length: 50 }, () => time('2026-09-15', 0.1, wp));
const second = [time('2026-09-16', 1, wp)];
const stub = await withStubGitea(
t,
healthyRoutes(REPO, {
'GET /api/v1/user/times': (req) => ({
status: 200,
body: req.query.page === '1' ? first : second,
}),
}),
);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.總計.實際秒, Math.round((50 * 0.1 + 1) * 3600));
});
test('工時內嵌的議題沒帶 body 時,補查議題才讀得到估算', async (t) => {
// /user/times 內嵌的議題不保證帶 body;少了它,估算會整欄靜靜變成 null
const bodyless = { ...issue(12, { days: 2 }) };
delete bodyless.body;
const stub = await withTimes(
t,
[time('2026-09-15', 20, bodyless), time('2026-09-16', 1, bodyless)],
{ [`GET /api/v1/repos/${REPO}/issues/12`]: { status: 200, body: issue(12, { days: 2 }) } },
);
const { json } = await run(stub, ['--today', '2026-09-17']);
assert.equal(json.data.議題[0].估算人天, 2);
assert.equal(json.data.總計.落差秒, (21 - 16) * 3600);
const lookups = stub.requests.filter((r) => r.path === `/api/v1/repos/${REPO}/issues/12`);
assert.equal(lookups.length, 1, '同一顆議題只補查一次,不是每筆工時各查一次');
});
test('唯一的非 GET 是前置檢查的寫入權探針,報表本身不寫任何東西', async (t) => {
const stub = await withTimes(t, [time('2026-09-15', 1, issue(12))]);
await run(stub, ['--today', '2026-09-17']);
// 四層前置檢查會 PATCH 不存在的議題 0 來實測 issues 寫入權,那一筆不改動任何東西。
// 除它以外整趟都該是 GET——報表只印在終端,不對任何管道張貼。
assert.deepEqual(
stub.requests.filter((r) => r.method !== 'GET').map((r) => `${r.method} ${r.path}`),
[`PATCH /api/v1/repos/${REPO}/issues/0`],
);
});
test('--dry-run 印出將發出的請求,且完全不碰 Gitea', async (t) => {
const stub = await withTimes(t, []);
const { code, json } = await run(stub, ['--today', '2026-09-17', '--dry-run']);
assert.equal(code, 0);
assert.equal(json.data.dryRun, true);
assert.deepEqual(json.data.requests, [{ method: 'GET', path: '/user/times' }]);
assert.equal(stub.requests.length, 0);
});
// ── 期間參數的把關 ─────────────────────────────────────────────────
test('三種期間彼此互斥', async (t) => {
const stub = await withTimes(t, []);
const { code, json } = await run(stub, ['--month', '2026-01', '--year', '2026']);
assert.equal(code, 1);
assert.equal(json.error.code, 'PERIOD_CONFLICT');
});
test('--month 需為 YYYY-MM', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--month', '2026/01']);
assert.equal(json.error.code, 'BAD_PERIOD');
assert.match(json.error.message, /--month/);
});
test('--month 的月份需在 01–12 之間', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--month', '2026-13']);
assert.equal(json.error.code, 'BAD_PERIOD');
});
test('--year 需為四位數', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--year', '26']);
assert.equal(json.error.code, 'BAD_PERIOD');
assert.match(json.error.message, /--year/);
});
test('--today 需為真實存在的日期', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--today', '2026-02-30']);
assert.equal(json.error.code, 'BAD_PERIOD');
assert.match(json.error.message, /--today/);
});
test('--day-hours 需為正數', async (t) => {
const stub = await withTimes(t, []);
const { json } = await run(stub, ['--day-hours', '0']);
assert.equal(json.error.code, 'BAD_DAY_HOURS');
});
test('--week 明講出來時與預設同一段期間', async (t) => {
const stub = await withTimes(t, [time('2026-09-15', 2, issue(12))]);
const explicit = await run(stub, ['--today', '2026-09-17', '--week']);
const implicit = await run(stub, ['--today', '2026-09-17']);
assert.deepEqual(explicit.json, implicit.json);
});
test('--today 搭到月報或年報時擋下,不默默忽略', async (t) => {
const stub = await withTimes(t, []);
const month = await run(stub, ['--month', '2026-01', '--today', '2026-09-17']);
const year = await run(stub, ['--year', '2026', '--today', '2026-09-17']);
assert.equal(month.json.error.code, 'PERIOD_CONFLICT');
assert.equal(year.json.error.code, 'PERIOD_CONFLICT');
});
test('期間標籤讓人一眼看出報表涵蓋什麼', async (t) => {
const stub = await withTimes(t, []);
const week = await run(stub, ['--today', '2026-09-17']);
const month = await run(stub, ['--month', '2026-01']);
const year = await run(stub, ['--year', '2026']);
assert.equal(week.json.data.期間.標籤, '2026-09-14 ~ 2026-09-17');
assert.equal(month.json.data.期間.標籤, '2026-01');
assert.equal(year.json.data.期間.標籤, '2026');
});
test('不帶 --today 時以系統日期為準,仍算得出本週', async (t) => {
const stub = await withTimes(t, []);
const { code, json } = await run(stub, []);
assert.equal(code, 0);
assert.match(json.data.期間.起, /^\d{4}-\d{2}-\d{2}$/);
assert.ok(json.data.期間.起 <= json.data.期間.迄);
});
-150
View File
@@ -1,150 +0,0 @@
/**
* sdlc-analyze 的交付物:四份可行性檢查清單與流程正本。
* 這一段不寫入 Gitea,所以沒有腳本——交付的就是這些文件本身。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { assertNeutralPrompt, promptStep, readPrompt, readReference } from './helpers/prompt-doc.js';
const prompt = readPrompt('sdlc-analyze');
/** 四類檢查,順序即提問順序 */
const CHECKS = [
{ kind: '架構', file: 'feasibility-architecture' },
{ kind: '邏輯', file: 'feasibility-logic' },
{ kind: '資料', file: 'feasibility-data' },
{ kind: '時程', file: 'feasibility-schedule' },
];
// ── 規則正本 ───────────────────────────────────────────────────────
test('四份可行性檢查清單各自存在且有檢查項', () => {
for (const { kind, file } of CHECKS) {
const reference = readReference(file);
const items = [...reference.matchAll(/^\d+\.\s+\*\*(.+?)\*\*/gm)];
assert.ok(items.length >= 4, `${kind}清單至少要有四條檢查項,目前 ${items.length} 條`);
}
});
test('每份清單都交代了「問題怎麼問」,不只列檢查項', () => {
for (const { kind, file } of CHECKS) {
assert.match(readReference(file), /## 問題怎麼問/, `${kind}清單缺少提問指引`);
}
});
test('各清單涵蓋議題點名的重點', () => {
assert.match(readReference('feasibility-architecture'), /循環相依/);
assert.match(readReference('feasibility-logic'), /既有功能/);
assert.match(readReference('feasibility-data'), /schema/);
assert.match(readReference('feasibility-data'), /遷移/);
assert.match(readReference('feasibility-data'), /交易邊界/);
assert.match(readReference('feasibility-schedule'), /最長路徑/);
assert.match(readReference('feasibility-schedule'), /未知數最大/);
});
// ── 流程正本 ───────────────────────────────────────────────────────
test('正本平台中立,description 前綴正確', () => {
assertNeutralPrompt(prompt, 'sdlc-analyze');
});
test('正本逐一指名四份規則正本,且順序為架構→邏輯→資料→時程', () => {
const positions = CHECKS.map(({ file }) => prompt.indexOf(`references/${file}.md`));
assert.equal(positions.every((p) => p >= 0), true, '四份清單都要被正本指名讀取');
assert.deepEqual([...positions].sort((a, b) => a - b), positions, '指名順序需為架構→邏輯→資料→時程');
});
test('正本明令一次只問一題', () => {
assert.match(prompt, /一次問一題/);
assert.match(prompt, /不要一次丟出/);
});
test('正本規定每題固定兩個選項:建議(含理由)與手動輸入', () => {
assert.match(prompt, /\*\*建議\*\*/);
assert.match(prompt, /理由/);
assert.match(prompt, /\*\*手動輸入\*\*/);
assert.match(prompt, /不被選項限制/);
});
test('正本要求前一類問完才進下一類', () => {
assert.match(prompt, /前一類的問題全部清空才進入下一類/);
});
test('正本規定最後輸出共識摘要,且摘要只印不寫', () => {
assert.match(prompt, /共識摘要/);
assert.match(prompt, /只印在終端/);
assert.match(prompt, /不寫回議題/);
});
test('正本把「共識摘要之前不寫入」寫成明確邊界', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /共識摘要之前不對 Gitea 寫入任何內容/);
assert.match(boundary, /不建議題/);
assert.match(boundary, /不留留言/);
});
test('第一段在共識摘要之前不寫入任何內容,碼錶是唯一的例外', () => {
const analysis = prompt.slice(prompt.indexOf('## 第一段'), prompt.indexOf('## 第二段'));
for (const writer of ['issue-create', 'issue-update', 'issue-link', 'project-add']) {
assert.equal(analysis.includes(writer), false, `第一段不該出現寫入型腳本 ${writer}`);
}
assert.match(analysis, /timer\.js/, '錶要起在第一段開頭,分析最耗時的正是共識之前那一段');
});
// ── 計時 ───────────────────────────────────────────────────────────
test('起錶排在第一段開頭,停錶排在最後的回報,兩者成對出現', () => {
const 起 = prompt.indexOf(promptStep(prompt, '起錶'));
assert.ok(起 > prompt.indexOf(promptStep(prompt, '讀議題')), '讀完議題確認它存在之後才起');
assert.ok(起 < prompt.indexOf('## 第二段'), '起錶要在第一段之內:分析最耗時的是共識之前那一段');
assert.match(promptStep(prompt, '停錶並回報'), /--stop/, '停錶與最後的回報寫在同一步');
});
test('起錶與停錶指的都是同一顆需求議題', () => {
const 計時段 = [...prompt.matchAll(/node scripts\/timer\.js[^\n]*/g)].map((m) => m[0]);
assert.ok(計時段.length >= 2, '起錶與停錶都要寫在正本裡');
for (const line of 計時段) {
assert.match(line, /--index <需求議題編號>/, `錶要起停在需求議題上:${line}`);
}
});
test('正本寫出這段計時涵蓋到哪,讀的人不必自己推', () => {
assert.match(prompt, /## 計時範圍/);
assert.match(prompt, /錶不跨階段跑/);
});
test('正本交代錶已經跑在同一顆上時不重起,不把累積時間切成兩段', () => {
assert.match(prompt, /切成兩段/);
});
test('正本明令不代停別顆議題上的錶', () => {
assert.match(prompt, /請他自己去停/);
assert.match(prompt, /不停別顆議題上的錶/);
});
test('邊界把碼錶從「不寫入」裡明文除外,並寫出理由', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /碼錶除外/);
assert.match(boundary, /工時/, '要說清楚碼錶記的是工時不是內容,否則下一個人會把它刪掉');
});
test('正本要求先看未處理留言數,不是 0 就提示先整併', () => {
assert.match(prompt, /未處理留言數/);
assert.match(prompt, /sdlc-sync/);
assert.match(prompt, /先停下來|先整併/);
});
test('正本指名由 issue-extract 讀議題,而不是自己讀全文', () => {
assert.match(prompt, /issue-extract/);
assert.match(prompt, /不必再讀整份議題全文/);
});
test('正本要求能自己查證的就不要拿去問使用者', () => {
assert.match(prompt, /能在程式碼裡查證的就自己去查/);
});
test('時程清單交代了「這階段還沒有工作包」該怎麼估', () => {
// 相依鏈最長路徑預設了一份拆法,而分析階段還沒有工作包可依
assert.match(readReference('feasibility-schedule'), /暫定拆法/);
assert.match(readReference('feasibility-schedule'), /還沒有工作包/);
});
-342
View File
@@ -1,342 +0,0 @@
/**
* 正本第一段「領取與開工準備」的規則。
*
* 這些檔案是文件不是程式,但它們是指令實際交付的東西:決策表寫錯,被鎖擋下來的人
* 就會得到錯的下一步;邊界寫漏,第一段就會去做後面幾段的事。靠人記不牢,用測試釘住。
*/
import test from 'node:test';
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 phase2 = prompt.slice(prompt.indexOf('## 第二段'), prompt.indexOf('## 第三段'));
const phase3 = prompt.slice(prompt.indexOf('## 第三段'), prompt.indexOf('## 邊界'));
test('輸入收得下 /sdlc-fix 交棒過來的編號,且不要求使用者重打指令', () => {
const 輸入 = prompt.slice(prompt.indexOf('## 輸入'), prompt.indexOf('## 第一段'));
assert.match(輸入, /sdlc-fix/);
assert.match(輸入, /交棒/);
assert.match(輸入, /不要因為是交棒來的就要求他再打一次指令/);
});
test('正本平台中立,description 前綴正確', () => {
assertNeutralPrompt(prompt, 'sdlc-feat');
});
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,
'領取之前要先讀得懂這顆在做什麼;錶則要等工作樹建好之後才起',
);
});
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 時要先停下來提示整併', () => {
assert.match(phase1, /未處理留言數/);
assert.match(phase1, /先停下來/);
assert.match(phase1, /sdlc-sync/);
});
test('領取鎖的四種狀態各自交代了下一步,含放行那一種', () => {
for (const code of ['CLAIMED_BY_OTHER', 'STOPWATCH_ON_THIS_ISSUE', 'STOPWATCH_ON_OTHER_ISSUE']) {
assert.match(phase1, new RegExp(code), `${code} 要出現在決策表裡`);
}
assert.match(phase1, /沒有鎖/, '第四種狀態(放行)也要在表上,否則只剩擋的那幾種');
assert.match(phase1, /不要繞過去/, '被擋下來的處置要明講,不能靠 agent 自由發揮');
});
test('缺標籤是前置條件,不混進領取鎖的四種狀態裡', () => {
const table = phase1.slice(phase1.indexOf('| 狀態'), phase1.indexOf('鎖以外還有一個前置條件'));
assert.equal(table.includes('LABEL_NOT_FOUND'), false, '它不是鎖的狀態,別讓四種變五種');
assert.match(phase1, /LABEL_NOT_FOUND/, '但仍要交代它,否則使用者不知道怎麼辦');
});
test('工作包跨多個 repo 時怎麼開分支,有交代', () => {
assert.match(phase1, /repos/);
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('別顆議題上的錶只由使用者自己停,並說明為什麼不代勞', () => {
assert.match(phase1, /別顆議題上的錶一律由使用者自己停/);
assert.match(phase1, /工時記錯地方/);
// 這一道自己起的那一支要自己停,否則錶會跨階段跑;兩件事不能混成一句「一律不停」
assert.match(phase1, /停掉的只有自己起的那一支/);
});
test('來源分支要問過使用者,且一次一題、附理由與手動輸入', () => {
assert.match(phase1, /一次問一題/);
assert.match(phase1, /手動輸入/);
assert.match(phase1, /不要替他決定|不要替使用者決定/);
});
test('翻譯規則釘住 kebab 與 40 字元上限,並舉出可照抄的例子', () => {
assert.match(phase1, /kebab/);
assert.match(phase1, /40/);
assert.match(phase1, /wp-extract-contract/, '要有一個真的例子,不要只說規則');
assert.match(phase1, /不要把長句截斷/);
});
test('--type 什麼時候要給、什麼時候不能給,寫清楚了', () => {
assert.match(phase1, /`--type` 只在來源是開發分支時要給/);
assert.match(phase1, /沿用來源/);
});
test('兩處「不覆蓋他人進度」的保證都有寫出來', () => {
assert.match(phase1, /起點一律是遠端的來源分支/, '根本不碰本機分支,就沒有覆蓋的可能');
assert.match(phase1, /接上去而不是蓋掉/);
});
test('三支寫入型腳本都要求先試跑', () => {
const dryRuns = phase1.match(/--dry-run/g) ?? [];
assert.ok(dryRuns.length >= 3, `三支寫入型腳本各要先試跑,只找到 ${dryRuns.length} 處`);
});
test('邊界把第一段不做的事分開列,且明講不寫本機狀態檔', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /不改任何一行程式碼/);
assert.match(boundary, /不勾待辦/);
assert.match(boundary, /不開 PR/);
assert.match(boundary, /不自行建立標籤/);
assert.match(boundary, /不在主工作區動手/);
assert.match(boundary, /不退回原地切分支/);
assert.match(boundary, /不寫任何本機狀態檔/);
assert.match(boundary, /換一台機器或換一個 agent/, '要說明為什麼不留狀態檔');
});
test('第三段收尾時告訴使用者之後怎麼查 PR,但不自己反覆跑', () => {
assert.match(phase3, /pr-watch\.js/);
assert.match(phase3, /worktree-remove\.js/, '手動清理的出口也要講,否則沒人知道它在');
assert.match(phase3, /不要自己反覆跑/);
assert.match(phase3, /suggestedAction/, '建議動作是列舉值,要讓使用者知道有這個東西可以判斷');
});
test('邊界擋住「監看報了就自己去跑 sdlc-fix」', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /不自動反覆執行 `pr-watch`/);
assert.match(boundary, /只由使用者明確叫用/);
});
// ── 第二段:逐項實作 ───────────────────────────────────────────────
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 的類型與描述/);
});
-145
View File
@@ -1,145 +0,0 @@
/**
* 流程正本與輸出模板的結構驗證。
*
* 這兩份是檔案而非程式,但它們是 #4 實際交付的東西:模板段落順序決定了下游
* issue-extract 解析得到什麼,正本的平台中立性決定了轉接檔能不能一份寫到底。
* 用測試釘住,比靠人記得住可靠。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
assertDiagramPlaceholderOnly,
assertNeutralPrompt,
assertPromptListsSections,
assertTemplateSections,
promptStep,
readPrompt,
readTemplate,
} from './helpers/prompt-doc.js';
const template = readTemplate('requirement-issue');
const prompt = readPrompt('sdlc-plan');
/** 某一步在正本裡的位置;以名字取而不是以編號取,插一步不會讓這些測試框錯段落 */
const at = (name) => prompt.indexOf(promptStep(prompt, name));
/** 需求議題的九個段落,順序即議題裡的順序 */
const SECTIONS = [
'總覽',
'背景',
'目標',
'非目標',
'領域名詞表',
'流程圖',
'驗收標準',
'影響範圍',
'未決事項',
];
// ── 輸出模板 ───────────────────────────────────────────────────────
test('模板依序包含九個段落', () => {
assertTemplateSections(template, SECTIONS);
});
test('模板以 {{變數}} 佔位,不留任何空白待填欄位', () => {
const placeholders = [...template.matchAll(/\{\{([^}]+)\}\}/g)].map((m) => m[1]);
assert.ok(placeholders.length >= SECTIONS.length, '每個段落至少要有一個佔位');
for (const name of placeholders) {
assert.match(name, /^[a-z一-龥]+$/u, `佔位名稱 ${name} 應為單一詞,不含空白或符號`);
}
});
test('總覽段落預留了總覽網頁的連結佔位', () => {
const overview = template.slice(template.indexOf('## 總覽'), template.indexOf('## 背景'));
assert.match(overview, /\{\{總覽\}\}/);
assert.match(overview, /\{\{總覽網頁\}\}/);
});
// ── 流程正本 ───────────────────────────────────────────────────────
test('正本平台中立,description 前綴正確', () => {
assertNeutralPrompt(prompt, 'sdlc-plan');
});
test('正本交代了三種輸入都要能吃', () => {
for (const kind of ['自由文字', '規格檔', '議題編號']) {
assert.match(prompt, new RegExp(kind), `正本要說明輸入可為${kind}`);
}
});
test('正本明令缺漏資訊要逐項問,不得自行編造', () => {
assert.match(prompt, /一次問一題|逐項詢問/);
assert.match(prompt, /不得(自行|替使用者)?(編造|填入)/);
});
test('正本釘住抽象圖表的節點上限與字數上限', () => {
assert.match(prompt, /抽象節點與邊|抽象/);
assert.match(prompt, /12/);
assert.match(prompt, /8\s*字/);
assert.match(prompt, /拆圖|omitted/);
});
test('正本要求標籤只能從既有標籤挑,並指名用 labels-list 取得', () => {
assert.match(prompt, /labels-list/);
assert.match(prompt, /不(得|能)(自行)?建立(新)?標籤/);
});
test('正本指名由 issue-create 寫入,並提醒先以 --dry-run 檢查', () => {
assert.match(prompt, /issue-create/);
assert.match(prompt, /--dry-run/);
});
test('正本逐一交代九個段落,且順序與模板一致', () => {
// 只看「組出議題內容」那份編號清單,不看散落在行文裡的提及
assertPromptListsSections(prompt, SECTIONS);
});
test('正本交代圖表由 renderer 重新產生', () => {
assert.match(prompt, /overview-render\.js/);
assert.match(prompt, /重新產生 SVG/);
assert.match(prompt, /產生 Mermaid/);
});
// ── 計時 ───────────────────────────────────────────────────────────
test('正本寫出這段計時涵蓋到哪,讀的人不必自己推', () => {
assert.match(prompt, /## 計時範圍/);
assert.match(prompt, /錶不跨階段跑/);
});
test('議題建立之前先記下開始時間:那時候還沒有標的可起錶', () => {
assert.ok(at('記下開始時間') < at('先試跑,再寫入'), '要在議題建立之前就記下');
assert.match(promptStep(prompt, '記下開始時間'), /不要憑印象回推/);
});
test('補登排在議題建立之後、起錶之前,三者指向同一顆議題', () => {
const 補登 = prompt.indexOf('time-log.js');
const 起錶 = prompt.indexOf('timer.js');
assert.ok(at('先試跑,再寫入') < 補登, '議題還不存在時無處可補');
assert.ok(補登 < 起錶, '順序反過來的話補登會被當成做過了而跳過');
for (const line of [...prompt.matchAll(/node scripts\/(?:time-log|timer)\.js[^\n]*/g)]) {
assert.match(line[0], /--index <編號>/, `補登與起錶要指向同一顆議題:${line[0]}`);
}
});
test('補登的長度由腳本算,不要 agent 自己做減法', () => {
assert.match(prompt, /--since <記下的開始時間>/);
assert.match(prompt, /不必自己做減法/);
});
test('補登不設時間上限,也不因為時間長就改口問使用者', () => {
assert.match(prompt, /不設時間上限/);
assert.match(prompt, /不必為此多長一題出來問使用者/);
});
test('停錶排在最後的回報那一步,與起錶成對', () => {
assert.match(promptStep(prompt, '停錶並回報'), /--stop/, '停錶與回報寫在同一步');
assert.ok(at('補登規劃時間,然後起錶') < at('停錶並回報'), '先起才有得停');
});
test('正本明令不代停別顆議題上的錶', () => {
assert.match(prompt, /請他自己去停/);
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /不停別顆議題上的錶/);
});
-109
View File
@@ -1,109 +0,0 @@
/**
* 工時報表的流程正本與輸出模板。
*
* 報表的難處是規則而不是程式:期間怎麼切、落差怎麼讀、印到哪裡為止。
* 規則寫在正本裡,錯了不會有任何測試自己爆掉,所以在這裡釘住。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
assertNeutralPrompt,
assertTemplateSections,
readPrompt,
readTemplate,
} from './helpers/prompt-doc.js';
const template = readTemplate('report');
const prompt = readPrompt('sdlc-report');
/** 報表的四個段落,順序即印出來的順序 */
const SECTIONS = ['總計', '分段小計', '逐議題', '附註'];
// ── 輸出模板 ───────────────────────────────────────────────────────
test('模板依序包含四個段落', () => {
assertTemplateSections(template, SECTIONS);
});
test('模板以 {{變數}} 佔位,不留任何空白待填欄位', () => {
const placeholders = [...template.matchAll(/\{\{([^}]+)\}\}/g)].map((m) => m[1]);
assert.ok(placeholders.length >= SECTIONS.length, '每個段落至少要有一個佔位');
for (const name of placeholders) {
assert.match(name, /^[a-z一-龥]+$/u, `佔位名稱 ${name} 應為單一詞,不含空白或符號`);
}
});
test('模板不含邏輯:沒有任何條件或迴圈語法', () => {
assert.equal(/\{\{[#/^]/.test(template), false, '分段與逐議題由呼叫端展開,模板不做迴圈');
});
test('總計把實際、估算與落差擺在同一列,落差不必自己算', () => {
const totals = template.slice(template.indexOf('## 總計'), template.indexOf('## 分段小計'));
for (const placeholder of ['{{實際工時}}', '{{估算人天}}', '{{已估實際}}', '{{落差}}']) {
assert.ok(totals.includes(placeholder), `總計缺少 ${placeholder}`);
}
});
// ── 流程正本 ───────────────────────────────────────────────────────
test('正本平台中立,description 前綴正確', () => {
assertNeutralPrompt(prompt, 'sdlc-report');
});
test('正本交代三種期間,並指明預設是本週', () => {
assert.match(prompt, /--week/);
assert.match(prompt, /--month YYYY-MM/);
assert.match(prompt, /--year YYYY/);
assert.match(prompt, /預設/);
});
test('正本釘住期間定義的三句話', () => {
assert.match(prompt, /一週為週一至週日/);
assert.match(prompt, /該週週五所屬月份/);
assert.match(prompt, /W1[–-]W5.*第幾個週五/s);
});
test('正本以實例說明跨月與跨年那一週落在哪邊', () => {
assert.match(prompt, /2025-12-29/, '跨年的例子要寫出具體日期,規則才驗得出來');
assert.match(prompt, /2026-02-01/, '跨月的例子要寫出具體日期');
});
test('正本指名由 report.js 取數字,並要求直接用算好的時分', () => {
assert.match(prompt, /report\.js/);
assert.match(prompt, /不要自己再乘|已經算好/);
});
test('正本明令只印在終端,不張貼到任何管道', () => {
assert.match(prompt, /只印在終端/);
assert.match(prompt, /不(要)?張貼/);
});
test('正本說明落差的正負方向,以及一人天等於幾小時', () => {
assert.match(prompt, /正數.*超出估算/);
assert.match(prompt, /8\s*小時/);
assert.match(prompt, /--day-hours/);
});
test('正本交代總計的落差只涵蓋有估算的議題', () => {
assert.match(prompt, /總計的落差只涵蓋有估算的議題/);
assert.match(prompt, /已估實際秒/, '要指名分子是哪一個欄位,否則會被讀成全部實際');
});
test('正本交代沒寫估算時落差是空的,不是零', () => {
assert.match(prompt, /不是零|非零|空的/);
assert.match(prompt, /null/);
});
test('正本交代略過的工時筆數要講出來', () => {
assert.match(prompt, /略過/);
});
test('正本的邊界寫明這是唯讀流程', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /不寫入|只發 GET/);
assert.match(boundary, /不補登|不動碼錶/);
});
test('正本交代週報沒有分段小計時整段不印', () => {
assert.match(prompt, /週報沒有分段/);
});
-196
View File
@@ -1,196 +0,0 @@
/**
* 補登工時。
*
* 規劃階段最耗時的那一段發生在議題建立之前——那時候沒有標的可起錶,時間只能事後補登。
* 這一支的價值全在「補多少」:長度由腳本自己算,不由 agent 做減法。終點看議題是不是
* 這一輪建立的——是就補到建立那一刻,不是就補到現在,那一輪的規劃時間照樣要進報表。
* 唯一不補的情形是錶已經在這顆議題上跑著,那一段已經有錶在記了。
*/
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 = 42;
/** 議題建立於此刻;每支測試的 --since 都相對它往前推 */
const CREATED = '2026-09-17T10:30:00Z';
/** CREATED 往前推 n 秒的 ISO 時間 */
const 早於建立 = (seconds) => new Date(Date.parse(CREATED) - seconds * 1000).toISOString();
/** 議題上跑著的碼錶長什麼樣 */
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: '為規劃與分析階段計時並補登規劃時間',
created_at: CREATED,
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}/times`]: { status: 200, body: { id: 1 } },
...overrides,
});
}
const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options));
const run = (since, args, stub) =>
runScript('time-log.js', ['--repo', REPO, '--index', String(INDEX), '--since', since, ...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(早於建立(25 * 60), [], stub);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.補登, true);
assert.equal(json.data.秒數, 25 * 60);
const [write] = writes(stub);
assert.equal(write.path, `/api/v1/repos/${REPO}/issues/${INDEX}/times`);
assert.deepEqual(write.body, { time: 25 * 60 });
});
test('長度由腳本自己算:議題的建立時間減掉 --since,agent 不必做減法', async (t) => {
const stub = await withStub(t);
const { json } = await run(早於建立(90), [], stub);
assert.equal(json.data.秒數, 90);
assert.equal(Date.parse(json.data.迄), Date.parse(CREATED), '終點就是議題建立那一刻');
assert.equal(json.data.依據, '議題建立');
});
test('不設時間上限:中間去開會的那幾個小時照實補登,不改口問使用者', async (t) => {
const stub = await withStub(t);
const 十小時 = 10 * 60 * 60;
const { code, json } = await run(早於建立(十小時), [], stub);
assert.equal(code, 0, '時間長不是失敗;記多了看得出來,記不到就永遠找不回來');
assert.equal(json.data.秒數, 十小時);
assert.deepEqual(writes(stub).at(0).body, { time: 十小時 });
});
test('錶已經跑在這顆議題上時跳過:補下去會與錶涵蓋的區間重疊', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX)] });
const { code, json } = await run(早於建立(600), [], stub);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.補登, false);
assert.match(json.data.note, /起錶/);
assert.deepEqual(writes(stub), [], '那一段已經有錶在記,補下去就記了兩遍');
});
test('對既有議題重跑時補到現在,那一輪的規劃時間照樣進報表', async (t) => {
// 議題比 --since 還早,代表這不是這一輪建立的;拿舊的建立時間當終點會算出負數,
// 等於把這一輪的工夫丟掉
const stub = await withStub(t);
const 起 = new Date(Date.now() - 20 * 60 * 1000).toISOString();
const { code, json } = await run(起, [], stub);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.補登, true);
assert.equal(json.data.依據, '補登當下');
assert.ok(Math.abs(json.data.秒數 - 20 * 60) <= 5, `補的應是這一輪的長度,實際 ${json.data.秒數}`);
assert.equal(writes(stub).at(0).body.time, json.data.秒數);
});
test('重跑是累計不是覆蓋:每一輪各記一筆,加總才是這顆議題真正的規劃時間', async (t) => {
const stub = await withStub(t);
const 起 = new Date(Date.now() - 10 * 60 * 1000).toISOString();
const 第一輪 = await run(起, [], stub);
const 第二輪 = await run(起, [], stub);
assert.equal(第一輪.json.data.補登, true);
assert.equal(第二輪.json.data.補登, true, '前一輪記過了不是跳過的理由');
assert.equal(writes(stub).length, 2, '兩輪各記一筆');
});
test('錶跑在別顆議題上不影響補登:補登只寫工時,不動任何錶', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(7)] });
const { code, json } = await run(早於建立(600), [], stub);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.補登, true);
assert.deepEqual(
writes(stub).map((r) => r.path),
[`/api/v1/repos/${REPO}/issues/${INDEX}/times`],
'不得順手停掉別顆議題上的錶',
);
});
test('指令開始時間落在未來時什麼都不補,也不算失敗', async (t) => {
const stub = await withStub(t);
const { code, json } = await run(new Date(Date.now() + 60 * 1000).toISOString(), [], stub);
assert.equal(code, 0, '兩邊時鐘差幾秒是常事,不該讓整個流程停在這裡');
assert.equal(json.data.補登, false);
assert.deepEqual(writes(stub), []);
});
test('--since 不是可解析的時間時擋在打 Gitea 之前', async (t) => {
const stub = await withStub(t);
const { code, json } = await run('剛剛', [], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'BAD_SINCE');
assert.equal(stub.requests.length, 0);
});
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(早於建立(600), [], stub);
assert.equal(json.error.code, 'ISSUE_NOT_FOUND');
assert.deepEqual(writes(stub), []);
});
test('--dry-run 印出將發出的補登,但一個字都不寫進去', async (t) => {
const stub = await withStub(t);
const { code, json } = await run(早於建立(1800), ['--dry-run'], stub);
assert.equal(code, 0);
assert.equal(json.data.dryRun, true);
assert.equal(json.data.秒數, 1800);
assert.deepEqual(json.data.requests, [
{ method: 'POST', path: `/repos/${REPO}/issues/${INDEX}/times`, body: { time: 1800 } },
]);
assert.deepEqual(writes(stub), [], '預覽不得真的寫入');
});
test('--dry-run 會先讀現況:錶已經在跑時預覽出來就是什麼都不做', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX)] });
const { json } = await run(早於建立(1800), ['--dry-run'], stub);
assert.deepEqual(json.data.requests, [], '手寫一份固定的清單會跟實作走鐘');
assert.equal(json.data.補登, false);
});
-244
View File
@@ -1,244 +0,0 @@
/**
* 起錶與停錶。
*
* 錶是工時報表的唯一來源,所以起錶那一半的價值全在「什麼時候不該起」:工作樹還沒建好
* 不該起(那由流程的順序保證),自己的錶已經跑在別顆議題上更不該起——那會把兩顆
* 工作包的時間攪在一起。
*
* `--stop` 只停 `--index` 指的那一顆。每個階段停掉自己起的那支錶,錶就不會跨階段跑;
* 但別顆議題上的錶一律不碰——那一段時間該記在哪顆議題上只有人知道,而靜默替人結算
* 正是領取鎖那條規則當初要擋的事。
*/
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: {} },
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`]: { status: 200, 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);
});
// ── 停錶 ───────────────────────────────────────────────────────────
test('--stop 停掉跑在這顆議題上的錶', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX)] });
const { code, json } = await run(['--stop'], stub);
assert.equal(code, 0, json.error?.message);
assert.equal(json.data.碼錶已停, true);
assert.deepEqual(
writes(stub).map((r) => `${r.method} ${r.path}`),
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`],
);
});
test('--stop 在錶本來就沒在跑時不算失敗,並說明這一步略過了', async (t) => {
const stub = await withStub(t);
const { code, json } = await run(['--stop'], stub);
assert.equal(code, 0, '這一步多半排在回報之前,報成失敗會讓人以為前面那件事沒做成');
assert.equal(json.data.碼錶已停, false);
assert.match(json.data.note, /沒在/);
assert.deepEqual(writes(stub), []);
});
test('--stop 不碰別顆議題上的錶:錶在別顆時什麼都不停', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(7)] });
const { code, json } = await run(['--stop'], stub);
assert.equal(code, 0);
assert.equal(json.data.碼錶已停, false);
assert.deepEqual(writes(stub), [], '靜默替人結算別顆議題,正是領取鎖那條規則要擋的事');
});
test('--stop 的 --dry-run 印出將發出的停錶,且不真的停', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX)] });
const { code, json } = await run(['--stop', '--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/stop`],
);
assert.deepEqual(writes(stub), []);
});
test('--stop 認得站台把「沒有錶在跑」回成 409 或 500 的兩種寫法', async (t) => {
for (const status of [409, 500]) {
const stub = await withStubGitea(
t,
routes(
{
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`]: {
status,
body: { message: 'cannot stop non existent stopwatch' },
},
},
{ stopwatches: [stopwatchOn(INDEX)] },
),
);
const { code, json } = await run(['--stop'], stub);
assert.equal(code, 0, `${status} 若說的是碼錶,就不是真的伺服器錯誤`);
assert.equal(json.data.碼錶已停, false);
}
});
test('--stop 遇到真的伺服器錯誤時照樣失敗,不吞掉', async (t) => {
const stub = await withStub(
t,
{
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`]: {
status: 500,
body: { message: 'database is on fire' },
},
},
{ stopwatches: [stopwatchOn(INDEX)] },
);
const { code, json } = await run(['--stop'], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'HTTP_ERROR');
});
-135
View File
@@ -1,135 +0,0 @@
/**
* 工作包議題的模板,以及正本裡「產生工作包」那一段的規則。
*
* 模板的段落順序決定 #9 的 wp-extract 解析得到什麼;待辦的巢狀寫法決定實作階段
* 勾得到哪一行。這兩件事寫死在測試裡,改動時才會被逼著一起改。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
assertDiagramPlaceholderOnly,
assertPromptListsSections,
assertTemplateSections,
readPrompt,
readTemplate,
} from './helpers/prompt-doc.js';
const template = readTemplate('work-package-issue');
const prompt = readPrompt('sdlc-analyze');
/** 第二段的內容,避免把第一段的字樣誤認成這一段的規則 */
const phase2 = prompt.slice(prompt.indexOf('## 第二段'), prompt.indexOf('## 第三段'));
/** 工作包議題的九個段落,順序即議題裡的順序 */
const SECTIONS = [
'這個工作包在做什麼',
'描述',
'架構圖',
'範圍邊界',
'介面契約',
'待辦',
'整體驗收',
'repo 列表',
'關聯',
];
// ── 輸出模板 ───────────────────────────────────────────────────────
test('模板依序包含九個段落', () => {
assertTemplateSections(template, SECTIONS);
});
test('模板每個段落都有 {{變數}} 佔位', () => {
const placeholders = [...template.matchAll(/\{\{([^}]+)\}\}/g)];
assert.equal(placeholders.length, SECTIONS.length);
});
test('介面契約是四欄表格:介面/產出者/消費者/形狀', () => {
const section = template.slice(template.indexOf('## 介面契約'), template.indexOf('## 待辦'));
assert.match(section, /\|\s*介面\s*\|\s*產出者\s*\|\s*消費者\s*\|\s*形狀\s*\|/);
assert.match(section, /\|\s*---\s*\|/, '要有分隔列,wp-extract 以它為界找資料列');
});
test('模板不把 mermaid 圍欄寫死:不畫圖時才不會留下渲染失敗的空區塊', () => {
assertDiagramPlaceholderOnly(template, '架構圖', '範圍邊界');
});
// ── 產生工作包那一段 ───────────────────────────────────────────────
test('第二段要等使用者對共識摘要點頭才開始', () => {
assert.match(phase2, /點頭之後才開始/);
assert.match(phase2, /沒有經過確認就不要往下走/);
});
test('正本逐一交代九個段落,且順序與模板一致', () => {
assertPromptListsSections(phase2, SECTIONS);
});
test('標題規則為動詞加名詞,且明令禁止流水編號', () => {
assert.match(phase2, /\{動詞\}\{名詞\}/);
assert.match(phase2, /禁止流水編號/);
assert.match(phase2, /WP-01/, '要舉出被禁止的寫法,不要只說「不要用編號」');
});
test('待辦的巢狀寫法有具體範例,且說明上層與縮排各代表什麼', () => {
const example = phase2.match(/```[^\n]*\n([\s\S]*?)```/)?.[1] ?? '';
const indents = example
.split('\n')
.filter((line) => line.trim().startsWith('- ['))
.map((line) => line.match(/^\s*/)[0].length);
assert.ok(indents.length >= 3, '範例要有數行待辦才看得出結構');
assert.ok(
Math.max(...indents) > Math.min(...indents),
'範例要真的有縮排出來的巢狀層,不能只用文字描述',
);
assert.match(phase2, /上層是待辦、縮排一層是該項的驗收/);
assert.match(phase2, /不要再往下巢狀/);
});
test('範圍邊界要求明列不做什麼', () => {
assert.match(phase2, /明列\*\*不做什麼\*\*/);
assert.match(phase2, /抵抗範圍蔓延/);
});
test('介面契約段落交代了沒有對外介面時怎麼填', () => {
assert.match(phase2, /不要留空表/);
});
test('關聯段落必須指回來源需求議題', () => {
assert.match(phase2, /需求議題:#/);
});
test('架構圖使用抽象資料並釘住節點與字數上限', () => {
assert.match(prompt, /抽象.*kind|抽象.*nodes/);
assert.match(prompt, /12/);
assert.match(prompt, /8\s*字/);
assert.match(prompt, /拆圖|omitted/);
});
test('寫入前先試跑,且試跑的價值有被說明', () => {
assert.match(phase2, /--dry-run/);
assert.match(phase2, /no-op/, '要說明試跑會顯示「實跑是 no-op」,否則使用者不知道該看什麼');
});
test('標籤只能從既有標籤挑,且指名用 labels-list 取得', () => {
assert.match(phase2, /labels-list/);
assert.match(phase2, /不得自行建立新標籤/);
});
test('冪等查重有被交代:重跑不會產生第二顆', () => {
assert.match(phase2, /重跑不會產生重複工作包/);
assert.match(phase2, /created.*false|`created` 設為 `false`/);
});
test('第二段明列它「不做」的事,避免搶走後續流程的工作', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /不建相依/);
assert.match(boundary, /不掛 Milestone/);
assert.match(boundary, /不加看板/);
assert.match(boundary, /不寫人天估算/);
});
test('缺少圖表資料時必須留下可追蹤原因', () => {
const limits = prompt.slice(prompt.indexOf('## 架構圖的限制'));
assert.match(limits, /拆圖|omitted/);
});
-654
View File
@@ -1,654 +0,0 @@
/**
* 工作包議題的抽取契約。
*
* 與需求議題那一支(issue-extract)的差別在三件事,測試也集中在這三件事上:
* 1. 待辦是巢狀的,而且每一項都要帶回未經修改的 `raw` —— 下游靠它精確勾選 checkbox。
* 2. 介面契約是四欄表格,四欄都要留著。
* 3. 議題 body 以外還要回報三個活狀態:相依、領取人、碼錶。
*
* 模板變體照樣要餵得夠雜:缺段落、巢狀驗收為空、checkbox 已勾、中英混排。
*/
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 = 9;
/** 一顆套好模板、九段俱全的工作包議題 */
const FULL_BODY = `## 這個工作包在做什麼
讓實作階段的指令能從工作包議題取得它需要的一切,而不必吞下整份議題全文。
## 描述
做完之後,實作指令給一個編號就拿得到待辦與驗收,不必人再讀一遍議題。
## 架構圖
\`\`\`mermaid
sequenceDiagram
實作指令->>wp-extract: 議題編號
wp-extract->>實作指令: 精簡 JSON
\`\`\`
## 範圍邊界
- 不讀留言內容,只回報未處理則數
- 不負責勾選 checkbox,那是 issue-update 的事
## 介面契約
| 介面 | 產出者 | 消費者 | 形狀 |
| --- | --- | --- | --- |
| wp-extract | 本工作包 | sdlc-feat | 單行 JSON |
| raw 欄位 | 本工作包 | issue-update | 原始 markdown 行 |
## 待辦
- [x] 解析九個段落
- [x] 缺段落回空值而不是報錯
- [ ] 圍欄裡的井字號不算標題
- [ ] 待辦解析成巢狀結構
- [ ] 每一項都帶未經修改的 raw
## 整體驗收
- [ ] 輸出欄位與契約完全一致
- [x] 模板變體各有測試案例並通過
## repo 列表
- plugins/tea-sdlc
## 關聯
需求議題:#1
估算人天:3
`;
function routes(overrides = {}, options = {}) {
const {
body = FULL_BODY,
comments = [],
assignee = null,
depends = [],
blocks = [],
stopwatches = [],
} = options;
const base = healthyRoutes(REPO, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
status: 200,
body: {
number: INDEX,
title: '建立工作包的抽取契約',
html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/${INDEX}`,
body,
assignee: assignee === null ? null : { login: assignee },
},
},
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/dependencies`]: {
status: 200,
body: depends.map((number) => ({ number })),
},
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/blocks`]: {
status: 200,
body: blocks.map((number) => ({ number })),
},
'GET /api/v1/user/stopwatches': { status: 200, body: stopwatches },
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: {
status: 200,
body: comments.map((c, i) => ({ id: 100 + i, body: c.body })),
},
});
comments.forEach((c, i) => {
base[`GET /api/v1/repos/${REPO}/issues/comments/${100 + i}/reactions`] = {
status: 200,
body: (c.reactions ?? []).map((content) => ({ content, user: { login: c.reactedBy ?? 'tester' } })),
};
});
return { ...base, ...overrides };
}
const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options));
const run = (args, stub) =>
runScript('wp-extract.js', ['--repo', REPO, '--index', String(INDEX), ...args], {
env: envFor(stub),
});
/** 這顆議題上跑著的碼錶長什麼樣 */
const stopwatchHere = { issue_index: INDEX, repo_owner_name: 'plugins', repo_name: 'tea-sdlc' };
// ── 契約 ───────────────────────────────────────────────────────────
test('抽出契約上的每一個欄位,不多也不少', async (t) => {
const stub = await withStub(t);
const { code, json } = await run([], stub);
assert.equal(code, 0);
assert.deepEqual(Object.keys(json.data).sort(), [
'index', 'url', 'title', 'assignee', 'repos', '相依',
'需求議題', '描述', '架構圖', '範圍邊界', '介面契約',
'待辦', '整體驗收', '碼錶中', '未處理留言數',
].sort());
});
test('議題本身的識別資訊原樣帶出', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.equal(json.data.index, INDEX);
assert.equal(json.data.url, `https://gitea.jsc.idv.tw/${REPO}/issues/${INDEX}`);
assert.equal(json.data.title, '建立工作包的抽取契約');
});
test('需求議題從關聯段落解析成編號', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.equal(json.data.需求議題, 1);
});
test('文字型段落回傳整段內容,架構圖連圍欄一起', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.match(json.data.描述, /實作指令給一個編號就拿得到待辦與驗收/);
assert.match(json.data.架構圖, /^```mermaid/);
assert.match(json.data.架構圖, /sequenceDiagram/);
assert.match(json.data.架構圖, /```$/);
});
test('範圍邊界與 repo 列表回傳字串陣列', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.deepEqual(json.data.範圍邊界, [
'不讀留言內容,只回報未處理則數',
'不負責勾選 checkbox,那是 issue-update 的事',
]);
assert.deepEqual(json.data.repos, ['plugins/tea-sdlc']);
});
test('整體驗收回傳字串陣列,勾選與否都只留文字', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.deepEqual(json.data.整體驗收, [
'輸出欄位與契約完全一致',
'模板變體各有測試案例並通過',
]);
});
// ── 介面契約:四欄都要留著 ─────────────────────────────────────────
test('介面契約保留四欄,表頭與分隔列不算一筆', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.deepEqual(json.data.介面契約, [
{ 介面: 'wp-extract', 產出者: '本工作包', 消費者: 'sdlc-feat', 形狀: '單行 JSON' },
{ 介面: 'raw 欄位', 產出者: '本工作包', 消費者: 'issue-update', 形狀: '原始 markdown 行' },
]);
});
test('介面契約只有表頭時回傳空陣列', async (t) => {
const body = '## 介面契約\n\n| 介面 | 產出者 | 消費者 | 形狀 |\n| --- | --- | --- | --- |\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.deepEqual(json.data.介面契約, []);
});
test('介面契約缺欄時補空字串,不讓欄位整個消失', async (t) => {
const body = '## 介面契約\n\n| 介面 | 產出者 | 消費者 | 形狀 |\n| --- | --- | --- | --- |\n| 無 | 本工作包 |\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.deepEqual(json.data.介面契約, [
{ 介面: '無', 產出者: '本工作包', 消費者: '', 形狀: '' },
]);
});
test('只寫一格的「無」也是一列,不會整張表變空', async (t) => {
// 正本明講「這顆不產出對外介面就寫一列『無』」,那一列不該與「沒有這一段」混為一談
const body = '## 介面契約\n\n| 介面 | 產出者 | 消費者 | 形狀 |\n| --- | --- | --- | --- |\n| 無 |\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.deepEqual(json.data.介面契約, [
{ 介面: '無', 產出者: '', 消費者: '', 形狀: '' },
]);
});
// ── 待辦:巢狀與 raw ───────────────────────────────────────────────
test('待辦解析成巢狀結構,驗收掛在它自己的待辦底下', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.deepEqual(json.data.待辦.map((todo) => todo.text), [
'解析九個段落',
'待辦解析成巢狀結構',
]);
assert.deepEqual(json.data.待辦[0].驗收.map((item) => item.text), [
'缺段落回空值而不是報錯',
'圍欄裡的井字號不算標題',
]);
assert.deepEqual(json.data.待辦[1].驗收.map((item) => item.text), [
'每一項都帶未經修改的 raw',
]);
});
test('勾選狀態如實反映在 done 上,待辦與驗收各自獨立', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.deepEqual(json.data.待辦.map((todo) => todo.done), [true, false]);
assert.deepEqual(json.data.待辦[0].驗收.map((item) => item.done), [true, false]);
});
test('每一項待辦與驗收都帶 raw,內容為未經修改的原始 markdown 行', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.equal(json.data.待辦[0].raw, '- [x] 解析九個段落');
assert.equal(json.data.待辦[0].驗收[0].raw, ' - [x] 缺段落回空值而不是報錯');
assert.equal(json.data.待辦[1].raw, '- [ ] 待辦解析成巢狀結構');
assert.equal(json.data.待辦[1].驗收[0].raw, ' - [ ] 每一項都帶未經修改的 raw');
});
test('raw 逐行出現在原始 body 裡,下游才替換得到', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
const lines = FULL_BODY.split('\n');
for (const todo of json.data.待辦) {
assert.ok(lines.includes(todo.raw), `raw 不在 body 裡:${todo.raw}`);
for (const item of todo.驗收) {
assert.ok(lines.includes(item.raw), `raw 不在 body 裡:${item.raw}`);
}
}
});
test('沒有驗收的待辦回傳空陣列,不是缺欄位', async (t) => {
const body = '## 待辦\n\n- [ ] 一項沒有驗收的待辦\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.equal(json.data.待辦.length, 1);
assert.deepEqual(json.data.待辦[0].驗收, []);
});
test('沒有 checkbox 的項目也收得到,done 為 false', async (t) => {
const body = '## 待辦\n\n- 忘了寫 checkbox 的待辦\n - 它的驗收\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.equal(json.data.待辦[0].text, '忘了寫 checkbox 的待辦');
assert.equal(json.data.待辦[0].done, false);
assert.equal(json.data.待辦[0].raw, '- 忘了寫 checkbox 的待辦');
assert.deepEqual(json.data.待辦[0].驗收.map((i) => i.text), ['它的驗收']);
});
test('大寫的 [X] 也算勾選', async (t) => {
const body = '## 待辦\n\n- [X] 大寫也是勾選\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.equal(json.data.待辦[0].done, true);
});
test('巢狀超過一層時攤進同一項的驗收,不無聲吃掉內容', async (t) => {
const body = '## 待辦\n\n- [ ] 上層待辦\n - [ ] 它的驗收\n - [ ] 不該存在的第三層\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.equal(json.data.待辦.length, 1);
assert.deepEqual(json.data.待辦[0].驗收.map((i) => i.text), [
'它的驗收',
'不該存在的第三層',
]);
});
test('沒有上層待辦的縮排項目升格成待辦,不被丟掉', async (t) => {
const body = '## 待辦\n\n - [ ] 開頭就縮排的項目\n- [ ] 後面才出現的上層\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.deepEqual(json.data.待辦.map((todo) => todo.text), [
'開頭就縮排的項目',
'後面才出現的上層',
]);
});
test('編號清單與符號清單一視同仁', async (t) => {
const body = '## 待辦\n\n1. [ ] 第一項\n2. [ ] 第二項\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.deepEqual(json.data.待辦.map((todo) => todo.text), ['第一項', '第二項']);
});
test('圍欄裡的待辦不是待辦', async (t) => {
const body = '## 待辦\n\n```\n- [ ] 範例裡的假待辦\n```\n\n- [ ] 真正的待辦\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.deepEqual(json.data.待辦.map((todo) => todo.text), ['真正的待辦']);
});
test('中英混排與行內標記都照原樣留著', async (t) => {
const body = '## 待辦\n\n- [ ] 讓 `wp-extract` 的 output 可被 downstream 直接使用\n - [ ] 支援 CJK 與 ASCII 混排\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.equal(json.data.待辦[0].text, '讓 `wp-extract` 的 output 可被 downstream 直接使用');
assert.equal(json.data.待辦[0].驗收[0].text, '支援 CJK 與 ASCII 混排');
});
// ── 模板變體 ───────────────────────────────────────────────────────
test('缺段落回傳空值而不是報錯', async (t) => {
const body = '## 描述\n\n只有描述的工作包。\n';
const stub = await withStub(t, {}, { body });
const { code, json } = await run([], stub);
assert.equal(code, 0);
assert.equal(json.data.描述, '只有描述的工作包。');
assert.equal(json.data.架構圖, '');
assert.equal(json.data.需求議題, null);
assert.deepEqual(json.data.範圍邊界, []);
assert.deepEqual(json.data.介面契約, []);
assert.deepEqual(json.data.待辦, []);
assert.deepEqual(json.data.整體驗收, []);
assert.deepEqual(json.data.repos, []);
});
test('議題完全沒有內容時不炸,所有欄位為空', async (t) => {
const stub = await withStub(t, {}, { body: '' });
const { code, json } = await run([], stub);
assert.equal(code, 0);
assert.equal(json.data.描述, '');
assert.deepEqual(json.data.待辦, []);
});
test('關聯段落沒寫需求議題時為 null,估算那一行不會被誤讀成編號', async (t) => {
const body = '## 關聯\n\n估算人天:3\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.equal(json.data.需求議題, null);
});
test('不認得的段落不影響其他段落', async (t) => {
const body = '## 描述\n\n有效內容。\n\n## 附錄\n\n- 不在契約裡的段落\n\n## 待辦\n\n- [ ] 仍然抓得到\n';
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.equal(json.data.描述, '有效內容。');
assert.deepEqual(json.data.待辦.map((todo) => todo.text), ['仍然抓得到']);
});
// ── CRLF:在 Gitea 網頁上編輯過的 body 就長這樣 ───────────────────
test('CRLF 的 body 照樣解析得出待辦、清單與表格', async (t) => {
// 瀏覽器送出 textarea 一律用 CRLF,議題只要被網頁編輯過就會變成這樣。
// 逐行切開後每一行都掛著 \r,正則若用 . 會整行比不中,清單靜靜變成空的。
const body = FULL_BODY.replace(/\n/g, '\r\n');
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
assert.deepEqual(json.data.待辦.map((todo) => todo.text), [
'解析九個段落',
'待辦解析成巢狀結構',
]);
assert.deepEqual(json.data.待辦[0].驗收.map((item) => item.text), [
'缺段落回空值而不是報錯',
'圍欄裡的井字號不算標題',
]);
assert.deepEqual(json.data.範圍邊界, [
'不讀留言內容,只回報未處理則數',
'不負責勾選 checkbox,那是 issue-update 的事',
]);
assert.deepEqual(json.data.repos, ['plugins/tea-sdlc']);
assert.equal(json.data.介面契約.length, 2);
assert.equal(json.data.需求議題, 1);
});
test('CRLF 的 raw 連行尾的 \\r 都留著,替換才對得上原文', async (t) => {
const body = FULL_BODY.replace(/\n/g, '\r\n');
const stub = await withStub(t, {}, { body });
const { json } = await run([], stub);
const lines = body.split('\n');
assert.equal(json.data.待辦[0].raw, '- [x] 解析九個段落\r');
assert.ok(lines.includes(json.data.待辦[0].raw));
assert.ok(lines.includes(json.data.待辦[0].驗收[0].raw));
});
// ── body 以外的活狀態 ─────────────────────────────────────────────
test('相依反映 Gitea 上實際的 blocks 與 depends', async (t) => {
const stub = await withStub(t, {}, { depends: [7], blocks: [11, 12] });
const { json } = await run([], stub);
assert.deepEqual(json.data.相依, { depends: [7], blocks: [11, 12] });
});
test('沒有相依時兩邊都是空陣列', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.deepEqual(json.data.相依, { depends: [], blocks: [] });
});
test('assignee 帶出領取人的帳號', async (t) => {
const stub = await withStub(t, {}, { assignee: 'jiantw83' });
const { json } = await run([], stub);
assert.equal(json.data.assignee, 'jiantw83');
});
test('沒人領取時 assignee 為 null', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.equal(json.data.assignee, null);
});
test('碼錶跑在這顆議題上時為 true', async (t) => {
const stub = await withStub(t, {}, { stopwatches: [stopwatchHere] });
const { json } = await run([], stub);
assert.equal(json.data.碼錶中, true);
});
test('碼錶跑在別顆議題上時為 false', async (t) => {
const stub = await withStub(t, {}, {
stopwatches: [{ ...stopwatchHere, issue_index: INDEX + 1 }],
});
const { json } = await run([], stub);
assert.equal(json.data.碼錶中, false);
});
test('同編號但不同 repo 的碼錶不算數', async (t) => {
const stub = await withStub(t, {}, {
stopwatches: [{ ...stopwatchHere, repo_name: '別的專案' }],
});
const { json } = await run([], stub);
assert.equal(json.data.碼錶中, false);
});
test('沒有任何碼錶時為 false', async (t) => {
const stub = await withStub(t);
const { json } = await run([], stub);
assert.equal(json.data.碼錶中, false);
});
// ── 留言:只數不讀 ─────────────────────────────────────────────────
test('未處理留言數只算沒有 +1 標記的留言', async (t) => {
const stub = await withStub(t, {}, {
comments: [
{ body: '這則已經整併過了', reactions: ['+1'] },
{ body: '這則還沒', reactions: [] },
{ body: '這則有別的 reaction 但不是 +1', reactions: ['heart'] },
],
});
const { json } = await run([], stub);
assert.equal(json.data.未處理留言數, 2);
});
test('只讀 body:留言內容一個字都不出現在輸出裡', async (t) => {
const stub = await withStub(t, {}, {
comments: [{ body: '留言裡提到的決策不該被抽出來', reactions: [] }],
});
const { stdout } = await run([], stub);
assert.equal(stdout.includes('留言裡提到的決策'), false);
});
// ── 錯誤 ───────────────────────────────────────────────────────────
test('議題不存在時回傳可區分的錯誤碼', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 404, body: { message: 'not found' } },
});
const { code, json } = await run([], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'ISSUE_NOT_FOUND');
assert.match(json.error.message, new RegExp(String(INDEX)));
});
test('沒有讀取權時的錯誤碼與「議題不存在」分得開', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 403, body: { message: 'forbidden' } },
});
const { json } = await run([], stub);
assert.equal(json.error.code, 'NO_READ_ACCESS');
});
test('--index 不是正整數時擋在打 Gitea 之前', async (t) => {
const stub = await withStub(t);
const { json } = await runScript('wp-extract.js', ['--repo', REPO, '--index', 'abc'], {
env: envFor(stub),
});
assert.equal(json.error.code, 'BAD_INDEX');
assert.equal(stub.requests.length, 0);
});
// ── --dry-run ─────────────────────────────────────────────────────
test('--dry-run 印出將發出的請求,且不碰 Gitea', 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}`),
[
`GET /repos/${REPO}/issues/${INDEX}`,
`GET /repos/${REPO}/issues/${INDEX}/dependencies`,
`GET /repos/${REPO}/issues/${INDEX}/blocks`,
'GET /user/stopwatches',
`GET /repos/${REPO}/issues/${INDEX}/comments`,
],
);
assert.match(json.data.note, /reaction/);
assert.equal(stub.requests.length, 0);
});
// ── 分頁 ───────────────────────────────────────────────────────────
test('留言逐頁讀完,不是只讀第一頁', async (t) => {
const page1 = Array.from({ length: 50 }, (_, i) => ({ id: 200 + i }));
const page2 = Array.from({ length: 20 }, (_, i) => ({ id: 300 + i }));
const reactions = {};
for (const { id } of [...page1, ...page2]) {
reactions[`GET /api/v1/repos/${REPO}/issues/comments/${id}/reactions`] = { status: 200, body: [] };
}
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: (req) => ({
status: 200,
body: req.query.page === '1' ? page1 : page2,
}),
...reactions,
});
const { json } = await run([], stub);
assert.equal(json.data.未處理留言數, 70);
});
test('相依逐頁讀完:半份清單會讓下游把順序排錯', async (t) => {
const page1 = Array.from({ length: 50 }, (_, i) => ({ number: 1000 + i }));
const page2 = [{ number: 2000 }];
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/dependencies`]: (req) => ({
status: 200,
body: req.query.page === '1' ? page1 : page2,
}),
});
const { json } = await run([], stub);
assert.equal(json.data.相依.depends.length, 51);
assert.equal(json.data.相依.depends.at(-1), 2000);
});
-68
View File
@@ -1,68 +0,0 @@
/**
* 正本第三段「排上時程與看板」的規則。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { assertNeutralPrompt, readPrompt } from './helpers/prompt-doc.js';
const prompt = readPrompt('sdlc-analyze');
const phase3 = prompt.slice(prompt.indexOf('## 第三段'), prompt.indexOf('## 架構圖的限制'));
test('正本仍然平台中立,description 前綴正確', () => {
assertNeutralPrompt(prompt, 'sdlc-analyze');
});
test('第三段指名四支腳本,順序為先算再寫', () => {
const order = ['schedule.js', 'issue-link.js', 'issue-update.js', 'project-add.js'];
const positions = order.map((name) => phase3.indexOf(name));
assert.equal(positions.every((p) => p >= 0), true, '四支腳本都要被指名');
assert.deepEqual([...positions].sort((a, b) => a - b), positions, '要先算出截止日才寫得下去');
});
test('計畫檔的格式有可照抄的範例', () => {
assert.match(phase3, /"startDate"/);
assert.match(phase3, /"workPackages"/);
assert.match(phase3, /"depends"/);
});
test('交代了拓撲排序保證什麼,以及成環時怎麼辦', () => {
assert.match(phase3, /截止日都不早於它的先決/);
assert.match(phase3, /成環/);
assert.match(phase3, /回頭改拆法/);
});
test('說明日期只算日曆日,不替使用者決定跳哪些日子', () => {
assert.match(phase3, /日曆日/);
assert.match(phase3, /不跳週末/);
});
test('三支寫入腳本都要求先試跑,並點出它們是冪等的', () => {
assert.match(phase3, /--dry-run/);
assert.match(phase3, /冪等/);
});
test('Milestone 與看板都只掛既有的,且交代反查不到時怎麼辦', () => {
assert.match(phase3, /只掛既有的/);
assert.match(phase3, /不建立 Milestone/);
assert.match(phase3, /不建立專案/);
assert.match(phase3, /貼專案網址/);
});
test('回報時要指出相依鏈最長路徑', () => {
assert.match(phase3, /相依鏈最長路徑/);
});
test('人天估算的 API 限制寫成獨立一節,不是藏在行文裡', () => {
const limit = prompt.slice(prompt.indexOf('## 已知限制'), prompt.indexOf('## 架構圖的限制'));
assert.match(limit, /time_estimate/);
assert.match(limit, /無法由 API 寫入/);
assert.match(limit, /估算人天:/, '要說清楚改寫到哪裡去');
assert.match(limit, /sdlc-report/, '要說清楚誰會讀這一行');
});
test('邊界把三段各自不做的事分開列', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /共識摘要之前不對 Gitea 寫入任何內容/);
assert.match(boundary, /第二段只建立工作包議題/);
assert.match(boundary, /第三段只掛既有的 Milestone 與看板/);
});