Compare commits
49
Commits
c2ca7fbf07
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d360ad266 | ||
|
|
2b61c4e235 | ||
|
|
fdd5696c7b | ||
|
|
6080cc99da | ||
|
|
43e91ceb70 | ||
|
|
c56f3271b5 | ||
|
|
52b5f08e7e | ||
|
|
957dd8795b | ||
|
|
37ecd93c6b | ||
|
|
0254c3abe1 | ||
|
|
ddc72a404e | ||
|
|
76cdedc980 | ||
|
|
744eee04ba | ||
|
|
a9ef078ef6 | ||
|
|
48b1e39e38 | ||
|
|
e0a26b81e5 | ||
|
|
280657573c | ||
|
|
8222a4043e | ||
|
|
0291002831 | ||
|
|
dd3a522b38 | ||
|
|
e98d1d0cbe | ||
|
|
378260b87a | ||
|
|
7fa7f7e1de | ||
|
|
437896348a | ||
|
|
a944141109 | ||
|
|
a5699d1798 | ||
|
|
20e3bfaaae | ||
|
|
e6b566b552 | ||
|
|
7f723adff8 | ||
|
|
16ef54c410 | ||
|
|
5a259a95af | ||
|
|
b35161248c | ||
|
|
867a23497f | ||
|
|
c545f11ec1 | ||
|
|
f61ef3fa78 | ||
|
|
84cd8fc41f | ||
|
|
3da822adc6 | ||
|
|
7995e74332 | ||
|
|
ad980fafc6 | ||
|
|
c06c327104 | ||
|
|
46c6db8c70 | ||
|
|
7ee050de2f | ||
|
|
81d02bffcf | ||
|
|
5638593c4c | ||
|
|
73b9cd9f75 | ||
|
|
a279591232 | ||
|
|
03ce382220 | ||
|
|
a5f36ed14c | ||
|
|
09d880e6f2 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tea-sdlc",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"description": "以 tea 驅動 SDLC 全流程的跨平台指令組(Claude Code / Codex / Antigravity / OpenCode / Copilot / Kiro / oh-my-pi)。流程正本為平台中立 markdown,由 tea-sdlc install 產生各平台轉接檔。",
|
||||
"skills": "./skills",
|
||||
"author": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tea-sdlc",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"description": "以 tea 驅動 SDLC 全流程的跨平台指令組。流程正本為平台中立 markdown,所有外部呼叫下沉到零相依 Node 腳本。",
|
||||
"skills": "./skills"
|
||||
}
|
||||
|
||||
@@ -17,16 +17,19 @@
|
||||
| 目錄 | 職責 | 邊界 |
|
||||
| --- | --- | --- |
|
||||
| `prompts/` | 流程正本(`sdlc-{plan,analyze,feat,fix,sync,report}.md`),唯一的事實來源 | 平台中立 markdown,不含任何平台專屬語法 |
|
||||
| `scripts/` | 所有副作用(Gitea API、git、檔案系統)的唯一出口 | Node、零外部套件,僅用內建 `fetch` / `child_process` / `fs` |
|
||||
| `templates/` | 所有產出格式(議題、PR、報表、總覽網頁) | 以 `{{變數}}` 佔位,不含邏輯。唯一例外是 `overview-artifact.html`:它是一份要在瀏覽器裡開的網頁,需要一段把 mermaid 圖畫出來的腳本 |
|
||||
| `references/` | 規則正本(實作規範、註解格式對照表、可行性檢查清單) | 由流程正本指名讀取,不自行散落於 prompts |
|
||||
| `scripts/` | 所有副作用、抽取、schema 驗證與 artifact 產生 | Node、零外部套件,僅用內建 `fetch` / `child_process` / `fs`;HTML、SVG、manifest 與附件生命週期也由此處負責 |
|
||||
| `templates/` | Markdown 產出格式(議題、PR、報表) | 以 `{{變數}}` 佔位,不含邏輯;HTML artifact 由 `scripts/overview-render.js` 產生 |
|
||||
| `references/` | 規則正本(實作規範、註解格式對照表、可行性檢查清單、委派判準、artifact 契約) | 由流程正本指名讀取,不自行散落於 prompts |
|
||||
| `bin/tea-sdlc.js` | 指令入口:取走子指令,其餘 argv 原樣交出去 | 不含任何平台目錄知識,也不自己動手做事 |
|
||||
| `scripts/install.js` | 平台偵測與轉接檔產生/移除 | 唯一知道各平台目錄結構的地方 |
|
||||
| `scripts/install-verify.js` | 安裝後走一遍叫用鏈(轉接檔 → PATH 上的 tea-sdlc → 流程正本) | 只認拿到的轉接檔路徑,不自己推導平台目錄;不碰網路 |
|
||||
| `skills/` | 各助理原生 plugin 機制讀取的 skills | 目前為空;指令以轉接檔形式佈署 |
|
||||
|
||||
## 慣例
|
||||
|
||||
- **零外部套件**:`package.json` 不得出現 `dependencies` 或 `devDependencies`。測試用 Node 內建 `node:test` + `node:assert`。
|
||||
- **委派標記雙向一致**:流程正本的 `〔可委派〕` 集合必須與 `references/delegation.md` 相同;任何變更同步更新資產測試與 ADR。
|
||||
- **文字編碼**:流程正本、規則正本、README、AGENTS.md 與 ADR 一律以 UTF-8 儲存,面向使用者的文字維持繁體中文。
|
||||
- **契約以議題為正本**:腳本的 flag 介面、JSON 輸出形狀、前置檢查與路徑定位規則,正本在[議題 #1](https://gitea.jsc.idv.tw/plugins/tea-sdlc/issues/1),實作時以該處為準;本檔不複寫,以免兩邊走鐘。
|
||||
- **測試**:`npm test`(等同 `node --test`)。測試產生的暫存一律寫到 `.tmp/`,該目錄已被 git 忽略,也不會被測試探索掃到。
|
||||
- **不改目標專案**:本 plugin 只讀目標專案的程式碼,不寫入目標專案的 `CLAUDE.md` 或任何設定檔。
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
# tea-sdlc
|
||||
|
||||
以 [tea](https://gitea.com/gitea/tea) 與 Gitea REST API 驅動 **SDLC 全流程**的跨平台指令組。
|
||||
把規劃、分析、實作、修正、整併、工時回報六個階段,固定成可重複、可被任何 coding agent 執行的流程。
|
||||
把規劃、分析、實作、修正、整併與報表停用流程,固定成可重複、可被任何 coding agent 執行的流程。
|
||||
|
||||
- **流程正本只有一份**:平台中立 markdown 放在 `prompts/`,改規則不會出現各平台版本分歧。
|
||||
- **副作用集中**:所有對 Gitea 與 git 的呼叫下沉到 `scripts/` 的零相依 Node 腳本,統一 JSON 輸入輸出。
|
||||
- **產出有固定形狀**:議題、PR、報表一律套 `templates/` 的模板。
|
||||
|
||||
> 六個流程正本都到齊了,`tea-sdlc install` 會把它們一次佈署到偵測到的平台。
|
||||
> 完整需求見[議題 #1](https://gitea.jsc.idv.tw/plugins/tea-sdlc/issues/1),進度見其底下的工作包。
|
||||
> 六個流程正本都到齊了;`tea-sdlc install` 會把它們一次佈署到偵測到的平台。委派標記以
|
||||
> `references/delegation.md` 為對照正本,資產測試會檢查雙向一致;流程與文件均以 UTF-8
|
||||
> 儲存並維持繁體中文。進度見
|
||||
> [議題 #1](https://gitea.jsc.idv.tw/plugins/tea-sdlc/issues/1) 底下的工作包。
|
||||
|
||||
---
|
||||
|
||||
@@ -19,9 +21,9 @@
|
||||
| `/sdlc-plan` | 把一段口語需求變成結構化的需求議題 |
|
||||
| `/sdlc-analyze` | 逐題把可行性疑點問到共識,據以產出工作包 |
|
||||
| `/sdlc-feat` | 領取工作包、開分支、逐項實作並開 PR |
|
||||
| `/sdlc-fix` | 處理 PR 上的留言 |
|
||||
| `/sdlc-fix` | 處理 PR 上的留言;收到議題編號則交棒給 `/sdlc-feat` |
|
||||
| `/sdlc-sync` | 把散落在留言裡的決策整併回議題描述 |
|
||||
| `/sdlc-report` | 產出週/月/年工時報表 |
|
||||
| `/sdlc-report` | 週報/月報/年報目前不可用,回傳 `REPORT_UNAVAILABLE` |
|
||||
|
||||
---
|
||||
|
||||
@@ -33,7 +35,7 @@ tea-sdlc/
|
||||
├── scripts/ # 所有 Gitea / git 副作用的唯一出口(Node,零相依)
|
||||
├── templates/ # 議題、PR、報表、總覽網頁的輸出模板
|
||||
├── references/ # 規則正本:實作規範、註解格式、可行性檢查清單
|
||||
├── bin/tea-sdlc.js # 單一指令入口:install / uninstall / prompt / status
|
||||
├── bin/tea-sdlc.js # 單一指令入口:install / uninstall / prompt / status / verify / sdlc-version
|
||||
├── skills/ # 各助理原生 plugin 機制讀取的 skills(目前為空)
|
||||
├── .claude-plugin/ # Claude Code 的 plugin / marketplace manifest
|
||||
├── .codex-plugin/ # Codex 的 plugin manifest
|
||||
@@ -41,6 +43,7 @@ tea-sdlc/
|
||||
├── plugin.json # Antigravity 的 plugin manifest
|
||||
├── package.json # npm 打包與測試入口,無任何相依套件
|
||||
├── AGENTS.md # 給 AI 助理的模組邊界與慣例
|
||||
├── docs/adr/ # 已接受的架構決策紀錄
|
||||
└── README.md
|
||||
```
|
||||
|
||||
@@ -54,9 +57,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 權限 |
|
||||
|
||||
---
|
||||
@@ -94,7 +95,7 @@ tea-sdlc install --dry-run
|
||||
| `claude`(Claude Code) | `~/.claude/` | `~/.claude/commands/sdlc-*.md` | command |
|
||||
| `codex`(Codex) | `~/.codex/` | `~/.codex/prompts/sdlc-*.md` | command |
|
||||
| `opencode`(OpenCode) | `~/.config/opencode/` | `~/.config/opencode/command/sdlc-*.md` | command |
|
||||
| `oh-my-pi` | `~/.omp/` | `~/.omp/commands/sdlc-*.md` | command |
|
||||
| `oh-my-pi` | `~/.omp/` | `~/.omp/agent/commands/sdlc-*.md` | command |
|
||||
| `antigravity`(Antigravity) | `~/.gemini/` | `~/.gemini/skills/sdlc-*/SKILL.md` | skill |
|
||||
| `kiro`(Kiro) | `~/.kiro/` | `~/.kiro/skills/sdlc-*/SKILL.md` | skill |
|
||||
| `copilot`(GitHub Copilot) | 專案裡的 `.github/` | `.github/skills/sdlc-*/SKILL.md` | skill |
|
||||
@@ -102,9 +103,35 @@ tea-sdlc install --dry-run
|
||||
轉接檔裡**沒有路徑**,只有一句「執行 `tea-sdlc prompt --name sdlc-plan`」。正本在哪由 PATH 上的
|
||||
`tea-sdlc` 自己回推——升級 Node、換版本管理器、改 npm prefix 都不會讓七個平台的轉接檔同時失效。
|
||||
|
||||
還沒裝 `git` 或 [`tea`](https://gitea.com/gitea/tea) 也可以先裝:轉接檔的產生不需要它們,
|
||||
安裝會把缺的東西列在輸出的 `missingBinaries` 與 `warning` 裡,但不會替你安裝,也不會因此中止。
|
||||
真正需要它們的是流程指令本身,跑之前補上即可。
|
||||
### 安裝完成等於驗過能用
|
||||
|
||||
寫完轉接檔之後,`install` 會把三層叫用鏈驗一遍:
|
||||
**轉接檔 → PATH 上的 `tea-sdlc` → AI Agent CLI 的 runtime registry**。
|
||||
它會確認 PATH 上的 `tea-sdlc` 能取回正本,並針對 Claude、Codex、oh-my-pi 與 OpenCode
|
||||
檢查其本機 command registry 是否包含全部六個流程。沒有安全、非互動 registry probe 的平台
|
||||
會明確回報 `not-supported`,不冒充驗證成功。結果放在輸出的 `verify.platforms[].runtime`:
|
||||
`status` 為 `pass`、`fail` 或 `not-supported`,並列出 `resolved`、`commands`、`missing`、
|
||||
`probe` 與 `remediation`。任何 `fail` 都讓 `install` 回 `ok:false`。
|
||||
|
||||
最脆弱的是中間那一環:套件裝在某個 Node 版本底下,換個版本就找不到了,而轉接檔本身看起來完全正常——
|
||||
沒有這道驗證,使用者要到第一次打 `/sdlc-plan` 才發現。
|
||||
|
||||
**驗不過也不會回滾**,已經寫好的轉接檔一份都不刪。回滾在升級情境下是淨損失:原本有一組能用的舊
|
||||
轉接檔,覆蓋後驗證失敗再刪掉,就從「有點舊但能用」變成什麼都沒有;何況最可能的病灶是「PATH 上
|
||||
找不到 `tea-sdlc`」,那不是轉接檔的問題。輸出的 `error.message` 會指出病灶與修復方式,修好之後
|
||||
重跑 `tea-sdlc install` 就好。
|
||||
|
||||
`--dry-run` 不寫入任何轉接檔,也就沒有東西可驗,`verify` 會標成 `skipped` 並說明原因。
|
||||
|
||||
安裝後可單獨重跑不寫檔的完整驗證:
|
||||
|
||||
```bash
|
||||
tea-sdlc verify
|
||||
tea-sdlc verify --platform oh-my-pi
|
||||
```
|
||||
|
||||
`tea-sdlc sdlc-version` 是無副作用的健康檢查,回報目前 `tea-sdlc` 的版本、PATH 實際
|
||||
解析到的 executable,以及可用流程;它不執行 AI Agent,也不連網。
|
||||
|
||||
---
|
||||
|
||||
@@ -118,6 +145,8 @@ tea-sdlc install --dry-run
|
||||
| --- | --- |
|
||||
| 更新到最新版 | `npm i -g git+https://gitea.jsc.idv.tw/plugins/tea-sdlc.git` |
|
||||
| 指令數量變了之後重新佈署 | `tea-sdlc install` |
|
||||
| 重新驗證已部署的 AI Agent CLI | `tea-sdlc verify` |
|
||||
| 查版本、PATH executable 與流程 | `tea-sdlc sdlc-version` |
|
||||
| 查目前版本、正本位置與環境 | `tea-sdlc status` |
|
||||
| 只移除轉接檔(正本不動) | `tea-sdlc uninstall` |
|
||||
| 連套件一起移除 | `tea-sdlc uninstall` → `npm rm -g tea-sdlc` |
|
||||
|
||||
+4
-1
@@ -15,15 +15,18 @@
|
||||
*/
|
||||
import { ScriptError, main } from '../scripts/lib.js';
|
||||
import { runInstall, runUninstall } from '../scripts/install.js';
|
||||
import { runVersion } from '../scripts/version.js';
|
||||
import { runVerify } from '../scripts/verify.js';
|
||||
import { runPrompt } from '../scripts/prompt.js';
|
||||
import { runStatus } from '../scripts/status.js';
|
||||
|
||||
/** 子指令名 → 實作。名字就是使用者打的字,也是轉接檔裡寫的字。 */
|
||||
const SUBCOMMANDS = {
|
||||
install: runInstall,
|
||||
uninstall: runUninstall,
|
||||
prompt: runPrompt,
|
||||
status: runStatus,
|
||||
'sdlc-version': runVersion,
|
||||
verify: runVerify,
|
||||
};
|
||||
|
||||
main(async () => {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
status: accepted
|
||||
---
|
||||
|
||||
# 以集中式雜湊路徑的 worktree 隔離平行工作包
|
||||
|
||||
`/sdlc-feat` 與 `/sdlc-fix` 一律在獨立的 worktree 上工作,而非在同一個工作目錄上切換分支;worktree 集中於 `~/.tea-sdlc/worktrees/{hash}`,`hash` 為正規化後 `owner/repo/分支名` 的 sha256 前 12 碼。這麼做的核心理由是 agent 非同步讀檔:它可能在分支被切走之後才去讀,而它不會察覺自己讀到的是別顆工作包的程式碼,產出看似合理卻接錯上下文——前兩種常見損耗(未提交變更擋路、建置產物跨分支混淆)人會當場發現,這一種不會,所以規則是「一律」而非「有衝突才用」。
|
||||
|
||||
## Considered Options
|
||||
|
||||
- **repo 內的 `.worktrees/`**:未被忽略時會出現在目標專案的 `git status`,而要它不出現就得改目標專案的忽略設定——本 plugin 明文不修改目標專案的檔案。
|
||||
- **目標 repo 的兄弟目錄**:不污染 repo,但會在使用者的專案父目錄長出一堆目錄,而那個目錄結構屬於使用者,不屬於這個工具。
|
||||
- **把分支名的斜線攤平成 `-` 當目錄名**:`feat/a-b/main` 與 `feat/a/b/main` 會撞成同一個名字,而既有的分支命名規則(`{類型}/{需求描述}/{功能描述}`)恰好讓這種形狀有機會出現。
|
||||
- **目錄名加可讀後綴**:被否決,因為 `git worktree list` 本來就會把分支名印在路徑旁邊,可讀性缺口有限。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 路徑由工作包**純函式推導**,不需要任何本機對照表或狀態檔,因此換機器或換 agent 之後推導結果相同;推導不到就重建,這正是「不寫入任何本機狀態檔」這條既有約束所要求的接手方式。
|
||||
- `git worktree add` 仍會在目標 repo 的 `.git/worktrees/` 底下寫中繼資料。這是 git 的機制,無法避免。「不修改目標專案的檔案」指的是專案內容檔,不含 git 自身的內部中繼資料——這條界線是本決策劃定的。
|
||||
- 目錄名是雜湊,光看路徑字串認不出是哪顆工作包。緩解來自兩處:`git worktree list` 印出分支名,PR 檢查腳本回報推導出的路徑。
|
||||
- 正規化(小寫、去前後空白)是必要的:沒有它,同一棵 worktree 會因輸入大小寫或多一個空白而被推導成兩個不同路徑。
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
status: accepted
|
||||
---
|
||||
|
||||
# 以能力描述而非工具名表達委派
|
||||
|
||||
流程正本在只在意結果的步驟上標記 `〔可委派〕`,並以**能力描述**說明怎麼委派——「你的環境若能把工作交給子代理,就交出去,只把結果帶回來;不能就自己做」——而不指名任何平台的子代理工具。子代理是平台專屬能力(Claude Code 與 Codex 有,Copilot/Kiro/OpenCode 不一定),而流程正本必須保持平台中立:這是 #4 已交付並打勾的驗收標準,也是 `AGENTS.md` 的模組邊界之一。能力描述對不支援的平台是自然降級,同一份正本兩邊都讀得通,不需要維護兩份。
|
||||
目前的標記集合是:`sdlc-plan` 列出九段落依據與缺漏、`sdlc-analyze` 對四份清單列出疑點與算出截止日,以及 `sdlc-feat` 把議題標題翻成英文與計算分批提交方案。分批提交的實際執行仍由主流程處理;完整對照以 `references/delegation.md` 為準。
|
||||
|
||||
## Considered Options
|
||||
|
||||
- **正本直接寫平台工具名**:最精確、agent 最不會誤判,但直接違反「流程正本不含任何平台專屬語法」,並且要為七個平台維護分歧的正本——那正是這個專案立「流程正本只有一份」時要防的事。
|
||||
- **由 `install.js` 產生轉接檔時依平台注入**:轉接檔只有一行指回正本,塞不下步驟級的指示;而「哪些步驟可委派」是流程知識,搬進 `install.js` 會污染它「唯一知道各平台目錄結構」的單一職責。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 這個寫法**刻意比它能做到的更模糊**。下一個讀到它的人第一反應很可能是「為什麼不直接寫工具名?」然後就把它改掉——這顆 ADR 存在的主要目的就是攔下那個修改。
|
||||
- 委派的判準(四條,見 `references/delegation.md`)與正本上的標記必須靠測試綁在一起:資產測試斷言「正本上被標記的步驟集合等於判準文件列出的集合」。沒有這條雙向斷言,兩邊會漂開,而漂開時不會有任何東西報錯。
|
||||
- 判準第二條(步驟中不會詢問使用者)與第四條(只產出草稿或唯讀結果,不直接寫入 Gitea 或 git)是硬排除,不是建議。前者因為子代理問不到使用者,後者因為子代理的失敗沒有人看著——備妥工作樹失敗會中止整個領取、實際提交失敗會留下半套 git 歷史,兩者都需要當場有人判斷下一步。
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tea-sdlc",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"type": "module",
|
||||
"description": "以 tea 驅動 SDLC 全流程的跨平台指令組。",
|
||||
"license": "MIT",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tea-sdlc",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"description": "以 tea 驅動 SDLC 全流程的跨平台指令組:規劃、分析、實作、修正、整併、工時回報。",
|
||||
"skills": "./skills/"
|
||||
}
|
||||
|
||||
+37
-211
@@ -3,242 +3,68 @@ description: 僅由 /sdlc-analyze 指令叫用。對一顆需求議題執行可
|
||||
|
||||
# sdlc-analyze
|
||||
|
||||
對一顆需求議題執行可行性檢查,把疑點一題一題問到雙方有共識,再把共識變成一批工作包議題。
|
||||
|
||||
分成三段:**可行性分析**到共識摘要為止,完全不寫入 Gitea;使用者看過摘要點頭之後,
|
||||
才進入**產生工作包**建立議題;最後**排上時程與看板**,把相依、截止日、Milestone、
|
||||
看板與人天估算補上。
|
||||
|
||||
這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。
|
||||
|
||||
## 輸入
|
||||
|
||||
一個需求議題編號。
|
||||
一個需求議題編號。先用 `scripts/issue-extract.js` 讀取結構化內容;先看 `未處理留言數`。若有留言,直接走 `/sdlc-sync` 的流程,完成後自動接回這裡;不要要求使用者重打指令。接回前重新抽取一次拿到更新後的描述;若使用者不整併,繼續並在摘要註明。
|
||||
|
||||
## 第一段:可行性分析
|
||||
|
||||
### 1. 讀議題
|
||||
### 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`
|
||||
|
||||
**先看 `未處理留言數`。** 只要不是 0,就代表議題描述可能是過期的——留言裡有決策還沒被
|
||||
整併回描述。這時**先停下來**告訴使用者有幾則未整併的留言,問他要不要現在整併。
|
||||
只產出可核對的疑點清單,不替使用者做決策;你的環境若能把工作交給子代理,就交出去,只把清單帶回來;不能就自己做。
|
||||
|
||||
要整併的話**直接走 `/sdlc-sync` 的流程**(`prompts/sdlc-sync.md`),做完**自動接回這裡**:
|
||||
重新抽取一次拿到更新後的描述,再往下走。**不要要求使用者重打指令**——他已經說要整併了。
|
||||
### 2. 逐題確認可行性共識
|
||||
|
||||
使用者選擇不整併就繼續,但要記下這件事,並在共識摘要裡註明「分析基於未整併留言前的描述」。
|
||||
能從程式碼查證的事項自行查證;只有需要使用者決策的事項才提問。架構、邏輯、資料、時程四類依序完成,每次只問一題,每題提供建議與理由,以及手動輸入的方式。最後輸出共識摘要、變更假設、未決事項與人天估算;摘要只印終端,不寫入 Gitea。
|
||||
|
||||
### 2. 對四份清單列出疑點
|
||||
### 交付文件判斷
|
||||
|
||||
依序讀這四份規則正本,逐條對照議題內容:
|
||||
使用者確認共識摘要後,讀取 `references/delivery-types.md`,列出這次需求需要交付或驗收的文件,並依序逐一詢問:
|
||||
|
||||
1. `references/feasibility-architecture.md` — 架構:放錯 repo、循環相依、穿越邊界。
|
||||
2. `references/feasibility-logic.md` — 邏輯:既有功能是不是已經做過同一件事。
|
||||
3. `references/feasibility-data.md` — 資料:schema 變更、遷移、交易邊界。
|
||||
4. `references/feasibility-schedule.md` — 時程:相依鏈最長路徑、未知數最大的一項。
|
||||
1. **需求描述概要**
|
||||
2. **WBS(工作分解結構)**
|
||||
3. **流程圖**
|
||||
4. **甘特圖**
|
||||
5. **PERT 圖**
|
||||
6. **關鍵路徑圖**
|
||||
7. **API 契約文件**
|
||||
|
||||
每一條檢查若在議題裡找不到答案,就轉成一個問題。**能在程式碼裡查證的就自己去查,
|
||||
不要拿去問使用者**——把問題留給只有人能回答的事。
|
||||
每一種文件都要先展示必要內容骨架,再給出是否需要的建議與理由,最後讓使用者確認、拒絕或手動調整;不得把七種文件合併成一次確認。確認結果要保留文件順序、必要內容、產出位置與 ELI5 變體規則。API 契約文件只能交付預覽或使用者確認的位置,禁止寫入目標專案 repo。
|
||||
|
||||
### 3. 逐題問到共識
|
||||
使用者未確認或拒絕任何一項時,立即停止,不建立工作包、不排程、不建立相依、不寫入 Milestone、看板或其他後續資料。
|
||||
|
||||
**一次問一題。** 問完等使用者回答,再問下一題,讓他能看著前一題的答案回答下一題。
|
||||
不要一次丟出五個問題,也不要把多個問題包成一題的多個選項。
|
||||
### PERT 三點估算
|
||||
|
||||
順序固定為**架構 → 邏輯 → 資料 → 時程**,前一類的問題全部清空才進入下一類。
|
||||
前面的答案常常會讓後面的問題消失或改寫;每問完一題,重新檢視剩下的問題還成不成立。
|
||||
|
||||
每一題固定給兩個選項:
|
||||
|
||||
- **建議** — 你的答案,附上理由。理由要寫「為什麼是這個」,不是複述問題。
|
||||
- **手動輸入** — 讓使用者自己寫。任何一題都必須能手動作答,不被選項限制。
|
||||
|
||||
問題本身要具體到能用一句話回答。問不出收斂答案的問題,多半是問題本身太大,拆開再問。
|
||||
|
||||
### 4. 輸出共識摘要
|
||||
|
||||
全部問完後,輸出一份摘要讓使用者做最後確認,內容包含:
|
||||
|
||||
- **每一類的結論** — 架構/邏輯/資料/時程各自問出了什麼,逐條列出「問題 → 答案」。
|
||||
- **改變了什麼** — 分析過程中翻掉或修正了需求議題裡的哪些假設。
|
||||
- **仍然未決的事** — 問了但沒有答案、或使用者明確說「之後再說」的事。
|
||||
- **人天估算** — 每一項的估算與最沒把握的那一項。
|
||||
|
||||
摘要只印在終端,**不寫回議題、不建立任何東西**。使用者看過點頭之後,才進入下一段。
|
||||
對每一個需要排程的工作包,分別逐題詢問樂觀時間(O)、最可能時間(M)、悲觀時間(P);每題都提供建議、理由與手動輸入方式。三個值都確認後才納入排程資料,不得用單一人天估算代替,也不得在確認前建立工作包或排程。
|
||||
|
||||
## 第二段:產生工作包
|
||||
|
||||
**使用者對共識摘要點頭之後才開始。** 摘要沒有經過確認就不要往下走。
|
||||
確認交付/驗收項目與所有必要的 PERT 三點估算後,依共識切出可獨立完成的工作包,套用 `templates/work-package-issue.md`。每顆工作包的待辦與驗收都要可逐項勾選;對應已確認交付項目的待辦置於第一項。
|
||||
|
||||
### 5. 切出工作包
|
||||
工作包排序先放已確認的交付文件工作包,再放純程式碼工作包;排序只在不違反先決關係時生效,若交付文件工作包依賴其他工作包,仍以拓撲順序為準。每顆工作包只做一種交付,並在描述中保留文件類型、必要內容、產出位置與驗收方式。
|
||||
|
||||
把需求切成幾顆工作包。一顆工作包是**開發者拿了就能動手、做完有明確結果**的單位:
|
||||
它有自己的驗收標準,做完能單獨被檢視,不必等別的工作包一起才看得出成果。
|
||||
每顆工作包先以 `scripts/issue-create.js --dry-run` 檢查,再移除旗標實跑。只使用既有標籤。建立後回報編號、標題與網址。
|
||||
重跑同一需求時,先以既有議題與標題查重;已存在的工作包只沿用其編號與相依,不建立重複工作包。
|
||||
|
||||
切的依據是第一段問出來的共識,特別是時程清單那份暫定拆法——那本來就是這一段的草稿。
|
||||
## 第三段:排程
|
||||
|
||||
**標題格式為「{動詞}{名詞}」**,例如「建立工作包的抽取契約」、「產生圖解版總覽網頁」。
|
||||
**禁止流水編號與任何無意義代號**(`WP-01`、`任務三`、`第一階段`):命名本身就要說明用途,
|
||||
看標題就知道這顆在做什麼,不必點進去。
|
||||
### 3. 算出截止日〔可委派〕
|
||||
|
||||
### 6. 組出每顆工作包的內容
|
||||
|
||||
套用 `templates/work-package-issue.md`,依序填滿九個段落:
|
||||
|
||||
1. **這個工作包在做什麼** — 一句話。讓人掃過標題與這一行就決定要不要點進來。
|
||||
2. **描述** — 從使用者的角度說這顆做完之後什麼事變得可能,不要寫成逐層的實作清單。
|
||||
3. **架構圖** — 見下方「架構圖的限制」。
|
||||
4. **範圍邊界** — 明列**不做什麼**。這一段的用途是抵抗範圍蔓延,寫得越具體越有用。
|
||||
5. **介面契約** — 表格,四欄:介面/產出者/消費者/形狀。讓人知道自己產出的東西誰會消費。
|
||||
這顆不產出對外介面就寫一列「無」,不要留空表。
|
||||
6. **待辦** — 巢狀結構:每一項待辦底下掛**它自己的**驗收標準,讓人知道這一項做到什麼程度算完成。
|
||||
|
||||
```
|
||||
- [ ] 建立共用函式庫
|
||||
- [ ] 具名 flag 解析可拒絕未知參數
|
||||
- [ ] 單行 JSON 輸出格式固定
|
||||
- [ ] 加上前置檢查
|
||||
- [ ] 四層各自回傳可區分的錯誤碼
|
||||
```
|
||||
|
||||
上層是待辦、縮排一層是該項的驗收,不要再往下巢狀。兩者都用 checkbox,實作時會被逐項勾選。
|
||||
7. **整體驗收** — 整顆工作包做完才驗得出來的事,與個別待辦的驗收不重複。
|
||||
8. **repo 列表** — 這顆會動到哪些 repo。
|
||||
9. **關聯** — 至少要有一行 `需求議題:#<編號>` 指回來源。阻擋、先決與人天估算由後續流程補上。
|
||||
|
||||
### 7. 先試跑,再寫入
|
||||
|
||||
每顆工作包各寫一個暫存檔,然後逐顆:
|
||||
|
||||
```
|
||||
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`。
|
||||
|
||||
### 8. 回報
|
||||
|
||||
列出每顆工作包的編號、標題與網址。不要把議題內容再貼一次。
|
||||
|
||||
## 第三段:排上時程與看板
|
||||
|
||||
工作包建好之後,把它們之間的關係與時程補上。做完這一段,看板上呈現的才是真實的
|
||||
開發順序,而不是一堆平鋪的議題。
|
||||
|
||||
### 9. 算出截止日
|
||||
|
||||
把每顆工作包的編號、人天估算與先決關係寫成一份計畫檔:
|
||||
|
||||
```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` 或人天調整過再算。
|
||||
|
||||
### 10. 逐顆補上關係與時程
|
||||
|
||||
對每一顆工作包,依序:
|
||||
|
||||
```
|
||||
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,也不建立專案。
|
||||
|
||||
### 11. 產生分析版的圖解總覽
|
||||
|
||||
用同一份 `templates/overview-artifact.html` 再產一份,但這一份要多出**工作包全景**:
|
||||
把工作包之間的相依與截止日畫成一張圖,讓開發者看得出自己這一項在整體中的位置。
|
||||
|
||||
全景圖用 `graph TD`,填進 `{{工作包全景}}`,連同段落標題一起:
|
||||
|
||||
```
|
||||
<section><h2>工作包全景</h2>
|
||||
<figure><div class="mermaid">graph TD
|
||||
A[建立共用函式庫<br/>09-25] --> B[建立抽取契約<br/>09-27]
|
||||
</div></figure></section>
|
||||
```
|
||||
|
||||
節點寫工作包標題與截止日,箭頭方向是「先決 → 後續」。節點一樣以 12 個為上限,
|
||||
超過就只畫相依鏈最長路徑上的那幾顆,其餘在頁尾列成文字。
|
||||
|
||||
網址一樣寫回需求議題:
|
||||
|
||||
```
|
||||
node scripts/issue-update.js --repo <owner/name> --index <需求議題編號> --overview-url <網址>
|
||||
```
|
||||
|
||||
**重跑會就地更新同一行**,不會在議題上留下兩個連結。規劃階段產生的那一份會被這一份取代,
|
||||
這是預期行為——同一顆需求議題只掛一個總覽網址。
|
||||
|
||||
### 12. 回報
|
||||
|
||||
列出每顆工作包的編號、標題、截止日與所屬 Milestone,並指出**相依鏈最長路徑**上的那幾顆
|
||||
——那條路徑決定整體交期。
|
||||
|
||||
## 已知限制:人天估算只寫得進 body
|
||||
|
||||
Gitea 1.27 的 API 沒有任何請求定義接受 `time_estimate`,該欄位只出現在議題的回應裡。
|
||||
也就是說**議題的估算欄位無法由 API 寫入**,只能靠人在網頁上填。
|
||||
|
||||
因此 `issue-update --estimate-days` 只把估算寫成議題 body 裡人類可讀的一行
|
||||
(`估算人天:N`,放在「關聯」段落)。之後 `sdlc-report` 要比對估算與實際工時時,
|
||||
讀的也是這一行。
|
||||
|
||||
## 架構圖的限制
|
||||
|
||||
依工作包的性質選圖:
|
||||
|
||||
- **`sequenceDiagram`** — 重點在「誰呼叫誰、順序為何」時用。
|
||||
- **`flowchart`** — 重點在「條件分支與資料流向」時用。
|
||||
- **`stateDiagram-v2`** — 重點在「狀態怎麼轉移」時用。
|
||||
|
||||
節點數上限 **12**,每個節點的文字上限 **8 字**。超過就拆成多張圖,或者乾脆不畫。
|
||||
|
||||
模板的 `{{架構圖}}` 要填入**完整的內容**,兩種形式擇一:
|
||||
|
||||
- 要畫:一個或多個完整的 ```mermaid 圍欄區塊。
|
||||
- 不畫:**只在超過上限拆不開、或畫了不會比文字更清楚時**才選這個,填一行說明為什麼不畫,**不要加圍欄**。
|
||||
所有工作包建立且相依關係確認後,只依已確認的 `startDate`、`days` 與 `depends` 呼叫 `scripts/schedule.js` 計算截止日;這一步只回傳可核對的日期與相依結果,你的環境若能把工作交給子代理,就交出去,只把結果帶回來;不能就自己做。寫入議題、Milestone、看板與其他後續資料不委派。
|
||||
計算完成後,依序用 `issue-link.js`、`issue-update.js` 與 `project-add.js` 補上既有相依、Milestone、看板、截止日與人天估算。各腳本先 dry-run,再實跑。日期與人天是排程資料,不是耗時統計。
|
||||
|
||||
## 邊界
|
||||
|
||||
- **共識摘要之前不對 Gitea 產生任何寫入**:不建議題、不改描述、不貼標籤、不留留言。
|
||||
- 第二段只建立工作包議題。不建相依、不掛 Milestone、不加看板、不寫人天估算——那是第三段的事。
|
||||
- 第三段只掛既有的 Milestone 與看板。不自行建立標籤、Milestone 或專案看板。
|
||||
- 不修改使用者的專案檔案。查證既有功能時只讀不寫。
|
||||
- 不替使用者決定他沒回答的事。問不到答案就進「仍然未決的事」。
|
||||
- 不關閉或刪除任何既有議題。
|
||||
- 不產生 HTML、SVG、manifest、截圖、附件或任何平台 preview。
|
||||
- 不建立 Milestone 或專案看板。
|
||||
- 不修改使用者專案檔案。
|
||||
- 不關閉或刪除既有議題。
|
||||
- 不把 API 契約文件寫入目標專案 repo。
|
||||
- 共識摘要、交付文件確認與 PERT 三點估算完成前,不建立任何工作包、不排程、不寫入後續資料。
|
||||
|
||||
+22
-333
@@ -1,354 +1,43 @@
|
||||
name: sdlc-feat
|
||||
description: 僅由 /sdlc-feat 指令叫用。領取一顆工作包、備妥工作樹、起錶,逐項實作並勾選待辦,最後分批提交並開立 PR。
|
||||
description: 僅由 /sdlc-feat 指令叫用。領取工作包、開分支、逐項實作並開 PR。
|
||||
|
||||
# sdlc-feat
|
||||
|
||||
拿一顆工作包,從領取到開出 PR。
|
||||
輸入一個工作包議題編號。先用 `scripts/wp-extract.js` 抽取;若抽取結果有 `未處理留言數`,直接走 `/sdlc-sync` 的流程,完成後自動接回這裡;不要要求使用者重打指令。接回前重新抽取一次拿到更新後的描述。
|
||||
|
||||
第一段**領取與開工準備**:把工作包安全地認領下來,備妥一棵屬於它的工作樹,然後開始計時。
|
||||
這一段不改任何一行程式碼——它只負責讓後面的實作有個乾淨的起點。
|
||||
依工作包 `repos` 逐一準備 worktree;不在主工作區切換分支。逐項完成待辦並立即勾選對應驗收。交付優先項目位於待辦第一項時先完成;仍須遵守工作包的依賴與範圍邊界。
|
||||
|
||||
第二段**逐項實作**:一項一項把待辦做完並即時勾選,讓議題頁的進度條隨時反映真實狀態。
|
||||
## 交付文件待辦分流
|
||||
|
||||
第三段**提交與開立 PR**:把變更整理成讀得懂的歷史,開出 PR,停錶。
|
||||
先把每一項待辦判定為程式碼待辦或交付文件待辦;兩者不可共用無差別的實作流程。交付文件待辦是需求描述概要、WBS、流程圖、甘特圖、PERT 圖、關鍵路徑圖或 API 契約文件等文件產出,判定依 `references/delivery-types.md` 的內容與產出位置。
|
||||
|
||||
這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。
|
||||
### 交付前逐一確認
|
||||
|
||||
## 輸入
|
||||
每一份交付文件都要分開處理。產出前先列出該類型的必要內容骨架、已知來源、未決事項、建議與理由,再一次只問一題確認內容是否齊全;未確認的文件不可標成已交付。能從需求、工作包、相依或排程重新推導的內容,保留來源與推導規則,不自行編造決策。
|
||||
|
||||
一個工作包議題編號。
|
||||
### 預覽能力分流
|
||||
|
||||
## 第一段:領取與開工準備
|
||||
以能力描述檢查目前環境是否能把該文件即時發佈成可開啟、可分享的預覽頁,並說明判定依據;不要靠固定清單或猜測環境。確認具備能力後直接產出預覽,回報實際位置與內容摘要。無法證明具備能力、預覽不可開啟或不可分享時,停止產出並一次只問一題,由 agent 依文件、來源與限制提出建議及理由,同時保留手動輸入,不替使用者決定交付方式。
|
||||
|
||||
### 1. 讀工作包
|
||||
API 契約文件只能交付在預覽位置或使用者確認的位置;不得寫入目標專案 repo,也不得藉由缺少預覽能力而改寫目標專案的設定檔或文件。預覽失效時回報實際限制,不捏造網址。
|
||||
|
||||
```
|
||||
node scripts/wp-extract.js --repo <owner/name> --index <編號>
|
||||
```
|
||||
### ELI5 變體
|
||||
|
||||
拿到的是結構化欄位:待辦與它自己的驗收、範圍邊界、介面契約、相依、repo 列表。
|
||||
不必再讀整份議題全文。
|
||||
使用者要求 ELI5 時,仍保留原文件的範圍、順序、相依、例外與驗收意義;把術語換成日常說法並補必要的短解釋,不刪除技術限制。圖表要重新繪製成容易閱讀的圖片式視覺,不把 Mermaid 原碼當成交付物,也不只用一個看似精確的日期隱藏 O/M/P 不確定性。
|
||||
### 把議題標題翻成英文〔可委派〕
|
||||
|
||||
**先看 `未處理留言數`。** 只要不是 0,就代表議題描述可能是過期的——留言裡有決策還沒被
|
||||
整併回描述。這時**先停下來**告訴使用者有幾則未整併的留言,問他要不要現在整併。
|
||||
把工作包議題標題轉成不超過 40 字元的英文 kebab slug,保留原意且不捏造新範圍;這一步只產出可驗證的 slug,你的環境若能把工作交給子代理,就交出去,只把 slug 帶回來;不能就自己做。若有兩個同樣合理的翻法,交回候選與差異,由主流程詢問使用者。
|
||||
|
||||
要整併的話**直接走 `/sdlc-sync` 的流程**(`prompts/sdlc-sync.md`),做完**自動接回這裡**:
|
||||
重新抽取一次拿到更新後的描述,再往下走。**不要要求使用者重打指令**——他已經說要整併了。
|
||||
## 實作與交付
|
||||
### 分批提交方案〔可委派〕
|
||||
|
||||
使用者選擇不整併就繼續,但要記下這件事,並在最後的 PR 描述裡註明「實作基於未整併留言前的描述」。
|
||||
依檔案類型與變更性質計算 commit 分類、順序與每批檔案;只回傳可核對的提交方案。實際執行 `scripts/commit-split.js`、處理失敗與確認 git 歷史不委派,由主流程自己完成。
|
||||
|
||||
**再看 `相依.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` | 多半是忘了停上一顆;先去停掉再回來 |
|
||||
|
||||
被錶擋下來時**要順帶說明停錶不會動到既有的工作樹**:碼錶只管時間、工作樹只管檔案。
|
||||
不講清楚,使用者會以為停錶等於放棄那顆工作包,於是寧可不停——工時就記到別顆去了。
|
||||
| 沒有鎖 | —— | 放行。自己已認領但沒起錶也算沒有鎖,那正是中斷後重跑的情形 |
|
||||
|
||||
碼錶一律由使用者自己停。哪一段時間該記在哪顆議題上只有他知道,代勞會把工時記錯地方。
|
||||
|
||||
鎖以外還有一個前置條件: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. 分批提交
|
||||
|
||||
全部待辦都勾完之後才進這一段。變更依類型分批:
|
||||
|
||||
```
|
||||
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 都要能直接接手。
|
||||
- 不修改工作包範圍外的檔案。
|
||||
- 不切換主工作區分支。
|
||||
- 不產生固定選項清單,不把平台工具名或呼叫語法寫入流程正本。
|
||||
- 不操作碼錶、不補登工時、不產生耗時統計。
|
||||
|
||||
+93
-8
@@ -1,17 +1,97 @@
|
||||
name: sdlc-fix
|
||||
description: 僅由 /sdlc-fix 指令叫用。定位工作包的工作樹,讀取 PR 上的三類留言,逐條處理並回覆,最後輸出修正摘要。
|
||||
description: 僅由 /sdlc-fix 指令叫用。收 PR 或議題編號:PR 就定位工作樹、逐條處理三類留言並回覆;議題則交棒給 /sdlc-feat。
|
||||
|
||||
# sdlc-fix
|
||||
|
||||
reviewer 留完意見,跑這一段,意見被逐條處理並回覆,不漏掉任何一則。
|
||||
|
||||
**意見不一定發在 PR 上。** 常常是發在工作包議題或需求議題的留言裡:使用者手上只有一個
|
||||
議題編號,看得到有人說「這裡要改」。所以輸入收得下三種東西,但只有 PR 在這裡處理完——
|
||||
議題交棒給 `/sdlc-feat`,不在這裡重做一遍它的流程。
|
||||
|
||||
這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。
|
||||
|
||||
## 輸入
|
||||
|
||||
一個 PR 編號。
|
||||
一個 PR 編號或議題編號。
|
||||
|
||||
## 1. 看 PR 現況,並定位工作樹
|
||||
## 1. 判定輸入是哪一種
|
||||
|
||||
三種輸入走三條不同的路,先問清楚是哪一種再動手。**判定沿用既有契約,不另發明判準**——
|
||||
標籤、標題前綴、編號區間都猜得出來,但猜錯的代價是整條路走錯。
|
||||
|
||||
先讀一次留言。它議題與 PR 都收得下,`類型` 欄位就是 PR 與議題的分界:
|
||||
|
||||
```
|
||||
node scripts/pr-comments.js --repo <owner/name> --index <編號>
|
||||
```
|
||||
|
||||
**每個 PR 都是議題,反過來不成立**,所以這個問題只能從議題那一端問:先打 PR 的端點,
|
||||
遇到純議題會 404,在讀到第一則留言之前就斷了。
|
||||
|
||||
`類型` 是 `議題` 時再問一次它是哪一種議題:
|
||||
|
||||
```
|
||||
node scripts/wp-extract.js --repo <owner/name> --index <編號>
|
||||
```
|
||||
|
||||
**`需求議題` 欄位(工作包的母議題)解析得出編號的就是工作包議題,解析不出來的就當需求
|
||||
議題。** 這是工作包抽取契約本來就有的欄位,`/sdlc-sync` 分這兩種議題用的也是同一份抽取。
|
||||
|
||||
| 判定 | 下一步 |
|
||||
| --- | --- |
|
||||
| `類型` 為 `PR` | 第 3 步起,這份正本後面每一步都是 PR 的路 |
|
||||
| `類型` 為 `議題`,抽得出 `需求議題` | 第 2 步的「工作包議題」 |
|
||||
| `類型` 為 `議題`,抽不出 `需求議題` | 第 2 步的「需求議題」 |
|
||||
|
||||
## 2. 議題:交棒給 /sdlc-feat
|
||||
|
||||
### 工作包議題
|
||||
|
||||
那一顆工作包該怎麼做,`/sdlc-feat`(`prompts/sdlc-feat.md`)已經從領取、備妥工作樹、
|
||||
逐項實作一路定到開出 PR。**照那份正本走,把這個編號當成它的輸入。**
|
||||
|
||||
**不要把它的步驟搬過來重講一遍**:那會製造第二份正本,兩邊遲早分岔——而使用者不會知道
|
||||
自己讀到的是哪一份。
|
||||
|
||||
交棒沿用 `/sdlc-sync` 那一套接回機制,只是方向相反:**直接接下去做,不要求使用者重打
|
||||
指令**。他已經給過這個編號了。
|
||||
|
||||
留言的整併不必在這裡先做——`/sdlc-feat` 的第一步就會看 `未處理留言數`,該整併時它自己
|
||||
會轉去 `/sdlc-sync`。在這裡先做一次,等於把那一步也抄了過來。
|
||||
|
||||
### 需求議題
|
||||
|
||||
需求議題上的留言**絕大多數是決策討論**,不是「這一行要改」。所以先整併,再談改碼:
|
||||
|
||||
1. 第 1 步的 `未處理數` 大於 0 就**走 `/sdlc-sync` 的流程**(`prompts/sdlc-sync.md`),
|
||||
做完**自動接回這裡**——重新讀一次留言,拿到的才是剛整併過的狀態。同樣不要求使用者重打指令。
|
||||
**`未處理數` 本來就是 0 的話這一步整個跳過**,直接往下選工作包:沒有留言要整併不表示
|
||||
沒有事要做,使用者是帶著「要改什麼」來的。
|
||||
2. 整併(與使用者選擇略過)之後還剩下的留言裡,挑出**確實要求改程式碼**的那幾則。判斷
|
||||
依據與第 5 步的「必改/建議」同一套:指出了錯誤、遺漏、會出事的寫法,或明確要求改動。
|
||||
3. **本來有留言,而整併完一則要改碼的都不剩,就到此為止。** 把整併了幾則、略過幾則講清楚,
|
||||
說明沒有要改碼的意見,然後停下來。先 sync 一次通常就清空了,選工作包那一步根本不會觸發。
|
||||
|
||||
確實有要改的(或一開始就沒有留言要整併),就列出這顆需求底下的工作包,讓使用者挑一顆:
|
||||
|
||||
```
|
||||
node scripts/wp-list.js --repo <owner/name> --requirement <需求議題編號>
|
||||
```
|
||||
|
||||
**不要因為「輸入不是工作包」就報錯。** 挑哪一顆他無論如何都要挑,報錯只是把這件事推回去
|
||||
讓他自己在 Gitea 網頁上翻。
|
||||
|
||||
一次問一題,選項是清單上的工作包(帶編號、標題、狀態、領取人),外加**手動輸入**——
|
||||
清單以「關聯」段落認歸屬,漏掉的那一顆他自己給得出編號。附上你的判斷:哪一顆的範圍
|
||||
涵蓋得到那幾則留言說的地方,以及為什麼。
|
||||
|
||||
清單是空的就照實說:這顆需求底下還沒有工作包,該跑的是 `/sdlc-analyze`,不是這一支。
|
||||
|
||||
挑定之後**交棒給 `/sdlc-feat`**,與上一小節同一條路。**把那幾則要求改碼的留言一起帶過去**,
|
||||
它們是這一輪要做的事;留言本身留在需求議題上不動,交棒不搬走任何人說過的話。
|
||||
|
||||
## 3. 看 PR 現況,並定位工作樹
|
||||
|
||||
先問一次現況,再決定要不要動手:
|
||||
|
||||
@@ -56,12 +136,14 @@ node scripts/worktree-ensure.js --repo <owner/name> --path <目標專案路徑>
|
||||
**後面每一步都在那棵工作樹裡做**,不要回到主工作區:它可能停在別的分支上,在那裡改
|
||||
會把改動落到別顆工作包的分支去。
|
||||
|
||||
## 2. 讀留言
|
||||
## 4. 讀留言
|
||||
|
||||
```
|
||||
node scripts/pr-comments.js --repo <owner/name> --index <PR 編號>
|
||||
```
|
||||
|
||||
第 1 步判定型別時讀的就是這一份,**手上那一份還在就直接用,不必再讀一次**。
|
||||
|
||||
三類留言一次讀齊:**一般留言**、**review 總評**、**行內留言**。每一則都帶 `id`、`作者`、
|
||||
`內容`、`已處理`,行內的還帶 `檔案`/`行`/`diff`——那段 diff 是判斷「他在說哪裡」的依據,
|
||||
不要略過不看。
|
||||
@@ -76,7 +158,7 @@ resolve。`已處理` 認的是**自己打的** `+1`——reviewer 對留言按
|
||||
偶爾會遇到 `可標記` 是 `false` 的總評(在 timeline 上對不到它在 issue comment 表裡的
|
||||
那一份)。那一則回覆照發,但沒有記號留得下來,**要在修正摘要裡單獨點出來**。
|
||||
|
||||
## 3. 分類:必改還是建議
|
||||
## 5. 分類:必改還是建議
|
||||
|
||||
逐則判斷,**reviewer 不必逐則說明**。判斷依據是內容本身:
|
||||
|
||||
@@ -87,7 +169,7 @@ resolve。`已處理` 認的是**自己打的** `+1`——reviewer 對留言按
|
||||
分類錯的代價不對稱——把必改當成建議會漏掉真的問題,所以拿不準時歸到必改那一邊,
|
||||
並在下一步問清楚。
|
||||
|
||||
## 4. 不確定就問
|
||||
## 6. 不確定就問
|
||||
|
||||
**一次問一題。** 下列情況不要自作主張:
|
||||
|
||||
@@ -102,7 +184,7 @@ resolve。`已處理` 認的是**自己打的** `+1`——reviewer 對留言按
|
||||
|
||||
**不確定卻硬改,比多問一題貴得多。** 改壞的地方 reviewer 下一輪才會看到。
|
||||
|
||||
## 5. 逐則處理並回覆
|
||||
## 7. 逐則處理並回覆
|
||||
|
||||
一則一則來:先改,改完立刻回覆那一則,再處理下一則。**不要全部改完才一起回**——
|
||||
中途斷掉的話,沒有人知道哪幾則已經處理過。
|
||||
@@ -126,7 +208,7 @@ node scripts/pr-reply.js --repo <owner/name> --index <PR 編號> \
|
||||
不必自己去翻 diff。決定不改的也要回,並說明理由——建議類的留言常常合理地不採納,
|
||||
但沉默會讓 reviewer 以為被忽略了。
|
||||
|
||||
## 6. 修正摘要
|
||||
## 8. 修正摘要
|
||||
|
||||
全部處理完後印一則摘要,讓 reviewer 不必逐串點開:
|
||||
|
||||
@@ -149,3 +231,6 @@ node scripts/pr-reply.js --repo <owner/name> --index <PR 編號> \
|
||||
- 不動 PR 的 review 狀態:回覆一則意見不該順手把整個 PR 標成通過或要求變更。
|
||||
- **不在主工作區處理留言**,一律在 `worktree-ensure` 定位出來的那棵工作樹裡。
|
||||
- PR 已經合併或關閉時不繼續處理留言,也不自己去刪推導路徑上的東西。
|
||||
- **不重做 `/sdlc-feat` 的步驟,也不把它的步驟抄進這份正本**:議題一律交棒過去。
|
||||
- 不因為輸入是議題就報錯要使用者改打別的指令,也不要求他把交棒過的指令重打一次。
|
||||
- 不替使用者決定要在哪一顆工作包上改:列出清單,讓他挑。
|
||||
|
||||
+30
-93
@@ -3,122 +3,59 @@ description: 僅由 /sdlc-plan 指令叫用。把一段口語需求轉成結構
|
||||
|
||||
# sdlc-plan
|
||||
|
||||
把使用者給的一段需求,變成一顆結構完整、下游指令讀得動的需求議題。
|
||||
|
||||
這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。
|
||||
|
||||
## 輸入
|
||||
|
||||
使用者給的東西可能是下列任一種,也可能三種混用:
|
||||
|
||||
- **自由文字** — 一段口語描述。
|
||||
- **規格檔** — 一個檔案路徑,內容是既有的規格或筆記。
|
||||
- **議題編號** — 既有議題的編號,用來補充脈絡或作為延伸的起點。
|
||||
|
||||
先把三種來源讀齊,再開始問問題。規格檔用檔案讀取工具讀;議題編號用
|
||||
`scripts/issue-extract.js` 取(若該腳本尚未可用,改用 `scripts/issue-create.js` 以外的
|
||||
既有讀取途徑,並在摘要中註明資料來源)。
|
||||
把自由文字、規格檔或既有議題整理成需求議題。
|
||||
|
||||
## 步驟
|
||||
|
||||
### 1. 讀齊輸入,列出還缺什麼
|
||||
開始前讀取 `references/requirements-discovery.md`。它是需求內容品質與建立前完整性閘門的規則正本;本流程只補充操作順序與 Gitea 交付步驟。
|
||||
|
||||
把九個段落逐一對照使用者給的材料,列出哪些段落已經有依據、哪些沒有。
|
||||
### 1. 列出九段落依據與缺漏〔可委派〕
|
||||
|
||||
### 2. 逐項詢問
|
||||
讀齊輸入,列出總覽、背景、目標、非目標、領域名詞表、文件、驗收標準、影響範圍與未決事項中已有依據與缺漏。對每一段不只檢查是否非空,還要依需求補全規則檢查實質內容。這一步只產出可核對的清單;你的環境若能把工作交給子代理,就交出去,只把清單帶回來;不能就自己做。
|
||||
|
||||
**一次問一題**,等使用者回答完再問下一題,讓他能看著前一題的答案回答下一題。
|
||||
### 2. 一次問一題補齊缺漏
|
||||
|
||||
每一題都附上你的建議與理由,讓使用者多數時候只要點頭;同時保留讓他自己寫答案的餘地。
|
||||
依需求補全規則挑出下一個最高價值的需求缺口。每次只問一題,並同時列出目前理解、缺少內容、為什麼需要,以及建議選項或短範例;明確允許使用者自由改寫答案。
|
||||
|
||||
**未獲得答覆的欄位不得自行編造。** 使用者沒說過的目標、沒提過的驗收標準,一個字都不能自己
|
||||
填。問不到就放進「未決事項」,那一段本來就是給未決的東西用的。
|
||||
能從輸入、既有議題或可取得的 repo 內容查證的事項先自行查證;只有需要需求擁有者決策的事項才提問。不得把推測或實作方案寫成已確認需求。
|
||||
|
||||
### 3. 組出議題內容
|
||||
每次收到回答後更新工作稿,重新檢查角色、情境、行為、可觀察結果,以及主要成功情境與適用的失敗/邊界情境。未回答、「不知道」或「尚未決定」仍是缺口,繼續一次問一題;只有使用者明確確認「不適用」並說明原因,才可標記該項完成。
|
||||
|
||||
套用 `templates/requirement-issue.md`,依序填滿九個段落:
|
||||
### 3. 建立前完整性閘門
|
||||
|
||||
1. **總覽** — 一句話講完這件事在做什麼,讓非技術的利害關係人不必讀完技術細節。圖解版總覽的
|
||||
連結此時先留空,由後續流程回填。
|
||||
2. **背景** — 不超過三行。為什麼現在要做這件事。
|
||||
3. **目標** — 可量測。寫得出「怎樣算達成」才算數。
|
||||
4. **非目標** — 明列這次不做什麼,用來抵抗範圍蔓延。
|
||||
5. **領域名詞表** — 這份需求裡會反覆出現的詞,各給一行定義,讓團隊對同一個詞的理解一致。
|
||||
6. **流程圖** — 見下方「流程圖的限制」。
|
||||
7. **驗收標準** — 逐條列出,每一條都要能被驗證。
|
||||
8. **影響範圍** — 會動到哪些 repo、哪些既有功能。
|
||||
9. **未決事項** — 問不到答案、或需要他人拍板的事。
|
||||
套用 `references/requirements-discovery.md` 的完整性閘門。九段落都必須有實質內容,或由使用者確認不適用並說明原因;不得留下未回答或「尚未決定」的缺口。未通過閘門前不得套用模板、查標籤或建立議題。
|
||||
|
||||
### 4. 挑標籤
|
||||
### 4. 填入需求議題模板
|
||||
|
||||
先用 `scripts/labels-list.js --repo <owner/name>` 取得該 repo 的既有標籤,**只能從這份清單裡
|
||||
挑**。找不到合適的就不貼。**不得自行建立新標籤** —— 標籤體系由專案維護者決定,不該在多個
|
||||
repo 之間長出雜草。
|
||||
套用 `templates/requirement-issue.md`,填入總覽、背景、目標、非目標、領域名詞表、文件、驗收標準、影響範圍與未決事項;全程使用繁體中文。
|
||||
|
||||
### 5. 先試跑,再寫入
|
||||
### 5. 取得既有標籤
|
||||
|
||||
把組好的內容寫到一個暫存檔,然後:
|
||||
用 `scripts/labels-list.js` 取得既有標籤,只能選既有標籤。
|
||||
|
||||
### 6. 試跑並建立議題
|
||||
|
||||
寫入前先執行:
|
||||
|
||||
```
|
||||
node scripts/issue-create.js --repo <owner/name> --title "<標題>" --body-file <暫存檔> \
|
||||
--labels "<標籤1,標籤2>" --dry-run
|
||||
node scripts/issue-create.js --repo <owner/name> --title "<標題>" --body-file <暫存檔> --labels "<標籤>" --dry-run
|
||||
```
|
||||
|
||||
`--dry-run` 會印出將要送出的請求而不真的寫入。確認無誤後拿掉該旗標再跑一次。
|
||||
確認內容後移除 `--dry-run` 實跑。重跑以標題查重,不建立重複議題。
|
||||
|
||||
同一段需求重跑不會產生第二顆議題:`issue-create` 以標題查重,發現同名議題就回傳既有那一顆
|
||||
並把 `created` 設為 `false`。
|
||||
## 文件限制
|
||||
|
||||
### 6. 產生圖解版總覽
|
||||
文件段落只填以下其中一種:
|
||||
|
||||
套用 `templates/overview-artifact.html`,把議題的總覽、目標與流程圖填成一份可以直接投影的
|
||||
網頁。這一份是給**非技術的利害關係人**看的:他們不必讀完技術細節就知道這件事在做什麼。
|
||||
- `待 /sdlc-analyze 產生`。
|
||||
- 抽象節點與邊的文字描述,不寫具體圖形語法。
|
||||
|
||||
模板的佔位對應如下,樣式不要動——版面與內容分開,改一邊不必碰另一邊:
|
||||
|
||||
- `{{標題}}` 需求議題標題
|
||||
- `{{來源議題}}` 指回議題的連結
|
||||
- `{{總覽}}` 一句話總覽
|
||||
- `{{目標}}` 目標,逐條包成 `<li>`
|
||||
- `{{流程圖}}` 流程圖的 Mermaid 原始碼(**不含**圍欄,圍欄是議題 markdown 用的)
|
||||
- `{{工作包全景}}` 規劃階段還沒有工作包,**填空字串**;這一段由分析階段補上
|
||||
- `{{頁尾}}` 產生時間與產生者
|
||||
|
||||
若執行環境能把 HTML 發佈成可分享的網址,就發佈;不能的話存成檔案,把路徑當成網址用。
|
||||
|
||||
拿到網址後寫回議題:
|
||||
|
||||
```
|
||||
node scripts/issue-update.js --repo <owner/name> --index <編號> --overview-url <網址>
|
||||
```
|
||||
|
||||
它把連結以固定前綴寫成總覽段落裡的一行,**重跑時就地更新同一行**,不會長出第二個連結;
|
||||
議題原本的 markdown 白話總覽一字不動——網頁是補充,不是取代。連結旁會自動附上
|
||||
「此連結預設為私有,組織外無法開啟」,因為讀到的人多半會想轉寄給組織外的人。
|
||||
|
||||
### 7. 回報
|
||||
|
||||
把議題編號與網址告訴使用者。不要把整份議題內容再貼一次 —— 連結點進去就看得到。
|
||||
|
||||
## 流程圖的限制
|
||||
|
||||
用 Mermaid 的 `flowchart`。節點數上限 **12**,每個節點的文字上限 **8 字**。
|
||||
|
||||
超過就拆成多張圖,或者乾脆不畫 —— 一張塞了二十個節點的圖,比沒有圖更難懂。
|
||||
|
||||
節點文字寫該步驟在做什麼,不要寫成編號或代號。
|
||||
|
||||
模板的 `{{流程圖}}` 要填入**完整的內容**,兩種形式擇一:
|
||||
|
||||
- 要畫:一個或多個完整的 ```mermaid 圍欄區塊。
|
||||
- 不畫:**只在超過上限拆不開、或畫了不會比文字更清楚時**才選這個,填一行說明為什麼不畫(例如「流程為單一直線,畫圖無助理解」),**不要加圍欄**。
|
||||
|
||||
圍欄寫在填入的內容裡而不是模板裡,否則不畫圖時會留下一個空的 mermaid 區塊,
|
||||
在議題頁上是一塊渲染失敗的紅字。
|
||||
plan 階段禁止產生 HTML、SVG、manifest、截圖、附件或任何平台 preview。
|
||||
|
||||
## 邊界
|
||||
|
||||
- 不修改使用者的專案檔案。這個流程只讀輸入、寫 Gitea 議題。
|
||||
- 不建立標籤、不建立 Milestone、不建立專案看板。
|
||||
- 不關閉或刪除任何既有議題。
|
||||
- 規劃階段本身已含問題釐清,因此寫入 Gitea 前不再設額外的確認點;`--dry-run` 就是那道關卡。
|
||||
- 不修改使用者專案檔案。
|
||||
- 不建立標籤、Milestone 或專案看板。
|
||||
- 不關閉或刪除既有議題。
|
||||
- 不產生任何預覽或 artifact。
|
||||
- 回報議題編號與網址,不重貼全文。
|
||||
|
||||
+7
-82
@@ -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。
|
||||
|
||||
@@ -89,8 +89,8 @@ node scripts/comments-merge.js --repo <owner/name> --index <編號> \
|
||||
|
||||
## 接回原本的指令
|
||||
|
||||
這個指令常常不是使用者自己叫的,而是 `/sdlc-analyze` 或 `/sdlc-feat` 發現有未整併留言後
|
||||
轉過來的。**整併完就直接接回去**,從原本那個指令被打斷的地方繼續,不要要求使用者重打一次。
|
||||
這個指令常常不是使用者自己叫的,而是 `/sdlc-analyze`、`/sdlc-feat` 或 `/sdlc-fix` 發現有
|
||||
未整併留言後轉過來的。**整併完就直接接回去**,從原本那個指令被打斷的地方繼續,不要要求使用者重打一次。
|
||||
|
||||
接回去之前先重跑一次抽取(`issue-extract`/`wp-extract`),拿到的才是剛更新過的描述——
|
||||
接著用舊的那一份做事,這一整段就白做了。
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# 委派判準
|
||||
|
||||
流程正本裡標著〔可委派〕的步驟,是照這份判準挑出來的。**四條全部成立才可委派**;
|
||||
任何一條不成立就不標,那一步一律自己做。
|
||||
|
||||
這份是規則正本,流程正本指名讀它,不把規則抄過去——抄過去就會有兩份各自演化的判準。
|
||||
|
||||
## 判準
|
||||
|
||||
1. **產出是可驗證的成品** — 交回來的東西呼叫端看得出對不對:一個英文 kebab 字串、
|
||||
一份日期表、一份疑點清單、一份 HTML。說不出「拿回來的該長什麼樣」的步驟不可委派,
|
||||
因為呼叫端沒有辦法判斷它做完了沒有。
|
||||
2. **步驟中不會詢問使用者** — **硬排除**。子代理問不到使用者,一旦卡在提問就只能自行
|
||||
決定,而它決定的那件事使用者從頭到尾不會知道。逐題問到共識、問來源分支、認不出語言
|
||||
就停下來問——這類步驟一律不標,無論它們看起來多像例行公事。
|
||||
3. **失敗能被呼叫端偵測** — 交不出東西、交回來的形狀不對,主流程當場看得出來並接手。
|
||||
失敗只會表現成「結果怪怪的」而不會表現成「失敗」的步驟不可委派。
|
||||
4. **只產出草稿或唯讀結果,不直接寫入 Gitea 或 git** — **硬排除**。理由不是子代理做不好,
|
||||
而是**它的失敗沒有人看著**:備妥工作樹失敗會中止整個領取,實際提交失敗會留下半套
|
||||
git 歷史,這兩種都需要當場有人判斷下一步。
|
||||
|
||||
## 怎麼委派
|
||||
|
||||
標記寫成標題後綴 `〔可委派〕`,並以**能力描述**說明怎麼做,不指名任何平台的工具:
|
||||
|
||||
> 這一步只在意結果;你的環境若能把工作交給子代理,就交出去,只把結果帶回來;不能就自己做。
|
||||
|
||||
子代理是平台專屬能力,而流程正本必須保持平台中立。能力描述對不支援的平台是自然降級,
|
||||
同一份正本兩邊都讀得通,不需要維護兩份。**不要把它改寫成工具名**——那會讓正本綁死在
|
||||
某一個助理上。
|
||||
|
||||
委派與否不改變產出:兩條路的結果必須一樣,差別只在中間產物留不留在主脈絡裡。
|
||||
|
||||
## 部分委派
|
||||
|
||||
一個步驟裡只有一半合判準時,**標記照下,並在該步寫明哪一半不委派**。這比整步不標好——
|
||||
不標的話那一半的中間產物照樣塞滿主脈絡;也比整步委派安全,因為第四條是硬排除。
|
||||
|
||||
目前有一步是這個形狀:分批提交的方案計算可委派,實際跑 `commit-split.js` 不委派。
|
||||
|
||||
## 目前標記為〔可委派〕的步驟
|
||||
|
||||
這張表與正本上的標記互為正本,**兩邊由資產測試雙向綁住**。改一邊就要改另一邊,
|
||||
否則測試會擋下來——沒有這條斷言,兩邊會漂開,而漂開時不會有任何東西報錯。
|
||||
|
||||
| 正本 | 步驟 | 委派範圍 |
|
||||
| --- | --- | --- |
|
||||
| `sdlc-plan` | 列出九段落依據與缺漏 | 產出可核對的清單 |
|
||||
| `sdlc-analyze` | 對四份清單列出疑點 | 全步 |
|
||||
| `sdlc-analyze` | 算出截止日 | 計算日期;寫回議題不委派 |
|
||||
| `sdlc-feat` | 把議題標題翻成英文 | 全步 |
|
||||
| `sdlc-feat` | 分批提交方案 | 方案計算;實際提交不委派 |
|
||||
|
||||
`sdlc-sync`、`sdlc-fix` 與 `sdlc-report` 目前沒有可委派的步驟:前兩者每一步都在問使用者
|
||||
或寫入 Gitea,後者只有一支唯讀腳本,委派出去省不到什麼。
|
||||
@@ -0,0 +1,60 @@
|
||||
# 交付類型規則
|
||||
|
||||
這份規則是 `/sdlc-analyze` 與 `/sdlc-feat` 共用的交付文件正本。交付文件不是額外的程式碼待辦;先確認要交付哪一種文件,再依本表逐一確認內容骨架,最後才產出。
|
||||
|
||||
## 共通規則
|
||||
|
||||
- 每一種文件都要逐一確認:說明是否需要、列出必要內容骨架、給出建議與理由,再接受使用者確認或手動調整;不得把七種文件合併成一次模糊確認。
|
||||
- 文件沒有足夠資料時標記未決事項,不代替使用者編造決策。能由需求、工作包、相依與排程資料重新推導的內容,優先保留來源與推導規則。
|
||||
- 產出位置以本表為準。預覽能力存在時可交付可開啟、可分享的預覽;沒有預覽能力時依使用者確認的方式交付,不因缺少預覽而捏造網址或改寫目標專案。
|
||||
- ELI5 變體要保留原文件的範圍、順序、相依、例外與驗收意義。可把術語換成日常說法並補一句解釋,但不能刪掉技術限制;圖表改成容易閱讀的視覺,不把 Mermaid 原碼當成 ELI5 交付物。
|
||||
|
||||
## 七種交付類型
|
||||
|
||||
### 1. 需求描述概要
|
||||
|
||||
- **必要內容**:一句話說明做什麼與為什麼做;背景;目標與可驗收結果;非目標;影響範圍;假設與未決事項;必要的領域名詞定義。
|
||||
- **產出位置**:需求議題的結構化描述,對應 `templates/requirement-issue.md` 的段落;可另外提供預覽,但議題內仍保留可機讀的白話概要。
|
||||
- **ELI5 變體**:先用一句日常語言說明問題和得到的改善,再用短句解釋必要術語;不得用願景口號取代目標、非目標或驗收條件。
|
||||
|
||||
### 2. WBS(工作分解結構)
|
||||
|
||||
- **必要內容**:可獨立交付的工作包;每個工作包的目標、範圍邊界、待辦與逐項驗收;工作包之間的先決與阻擋關係;交付文件工作包優先於純程式碼工作包,但不得違反先決關係。
|
||||
- **產出位置**:工作包議題的 `待辦` 與巢狀 `驗收`,以及 `整體驗收`、`repo 列表`、`關聯` 段落;不把 WBS 寫入目標專案。
|
||||
- **ELI5 變體**:把每個工作包說成一個能交付的箱子,說清楚箱子裡有什麼、完成的判準,以及哪個箱子要先完成;不得只列職責或模糊階段名稱。
|
||||
|
||||
### 3. 流程圖
|
||||
|
||||
- **必要內容**:起點、終點、主要步驟、分支條件、例外路徑與步驟間的方向;節點與邊都要能從需求或工作包驗證;超過可讀範圍時拆圖或改用文字。
|
||||
- **產出位置**:交付文件預覽或使用者確認的文件位置;需求議題只保留抽象節點與邊的文字描述,不產生 HTML、SVG、附件或平台 preview。
|
||||
- **ELI5 變體**:用「先做什麼、接著看什麼、遇到哪種情況走哪條路」描述;保留失敗與回復路徑,圖表視覺化時不嵌入 Mermaid 原碼。
|
||||
|
||||
### 4. 甘特圖
|
||||
|
||||
- **必要內容**:每個工作包或任務、開始日、截止日、工作日數、先決關係、交付里程碑與目前可辨識的重疊;日期必須能由排程資料重算。
|
||||
- **產出位置**:排程/交付文件預覽與終端摘要;Gitea 工作包議題只保留可追蹤的截止日、里程碑與關聯,不把圖表檔寫入目標專案。
|
||||
- **ELI5 變體**:把它說成一張「每件事什麼時候開始、什麼時候完成、誰要等誰」的日曆;不以顏色或位置暗示未列出的依賴。
|
||||
|
||||
### 5. PERT 圖
|
||||
|
||||
- **必要內容**:任務節點、先決關係、樂觀時間(O)、最可能時間(M)、悲觀時間(P)、期望時間與不確定性;三點估算要逐項向使用者確認,不能默認成單一工期。
|
||||
- **產出位置**:排程/交付文件預覽與終端摘要;O/M/P 與推導結果是排程資料,不寫入目標專案 repo。
|
||||
- **ELI5 變體**:把 O/M/P 說成最快、通常、最慢三種情況,指出哪一段最不確定;不得只報一個看似精確的日期而隱藏風險。
|
||||
|
||||
### 6. 關鍵路徑圖
|
||||
|
||||
- **必要內容**:完整相依網路、每個節點的工期、最長路徑、路徑總工期、關鍵任務與可用浮時;若有多條同長路徑要全部列出;相依成環要先報錯。
|
||||
- **產出位置**:排程/交付文件預覽與終端摘要;工作包議題保留相依關係與截止日作為可重算來源,不把圖表檔寫入目標專案。
|
||||
- **ELI5 變體**:說明「哪一串事情任何一件延遲都會讓最後交付延遲」,同時列出不在關鍵路徑上的緩衝;不得把所有工作都稱為關鍵。
|
||||
|
||||
### 7. API 契約文件
|
||||
|
||||
- **必要內容**:介面名稱、用途、產出者、消費者、輸入與輸出形狀、成功與錯誤情境、相容性限制、可驗收範例與來源;只記錄已確認的契約,未知內容列為未決事項。
|
||||
- **產出位置**:預覽或使用者確認的交付文件位置,以及可由需求議題、工作包與實作重新產生的摘要;**禁止把 API 契約文件寫入目標專案 repo**,也不得自動修改目標專案的設定檔或文件。
|
||||
- **ELI5 變體**:把 API 說成「誰用什麼資料提出請求,會拿到什麼回覆,出錯時會收到什麼」;保留欄位名稱、資料型別、錯誤碼與相容性限制,不用白話改寫掉可執行的契約。
|
||||
|
||||
## 確認與重產
|
||||
|
||||
- 每份文件產出前都先展示該類型的必要內容骨架,逐一確認內容是否齊全;使用者拒絕或未確認時不把它標成已交付。
|
||||
- 文件摘要必須保留來源議題、工作包、相依與排程資料的指向。來源更新後,摘要可由同一份來源重新產生,不以手工複製的摘要作為唯一真相。
|
||||
- 預覽失效、不可分享或環境不具備預覽能力時,回報實際能力與限制並逐題詢問交付方式;不得回退成寫入目標專案 repo,尤其是 API 契約文件。
|
||||
@@ -0,0 +1,59 @@
|
||||
# 需求補全規則
|
||||
|
||||
這份文件是 `/sdlc-plan` 判斷需求內容是否足夠清楚、可驗收的規則正本。
|
||||
|
||||
## 需求的四個必要維度
|
||||
|
||||
每個需要實作的需求都要能回答:
|
||||
|
||||
1. **角色** — 誰需要這個結果,或誰執行這個行為?
|
||||
2. **情境** — 在什麼觸發條件、前置條件或使用情境下發生?
|
||||
3. **行為** — 角色做了什麼,或系統需要處理什麼?
|
||||
4. **可觀察結果** — 外部使用者、呼叫端或驗收者能觀察到什麼結果?
|
||||
|
||||
只寫功能名稱、畫面名稱、API 名稱或實作方法,不足以視為完成。
|
||||
|
||||
## 情境覆蓋
|
||||
|
||||
- 至少確認主要成功情境。
|
||||
- 若需求有權限、取消、重試、空值、重複執行、並行、極大量或外部來源失敗等可能性,逐一確認適用的失敗或邊界情境。
|
||||
- 不適用的情境必須由使用者明確確認「不適用」,並說明原因;agent 不得自行判定。
|
||||
|
||||
## 提問格式
|
||||
|
||||
一次只問一個最高價值的缺口。每題依序提供:
|
||||
|
||||
- **目前理解**:根據輸入與已確認回答整理的一句話。
|
||||
- **缺少內容**:指出哪個必要維度或情境仍不清楚。
|
||||
- **為什麼需要**:說明它會影響哪個目標、範圍或驗收結果。
|
||||
- **建議回答**:提供具體選項或短範例。
|
||||
- **自由回答**:明確允許使用者改寫或提供其他答案。
|
||||
|
||||
建議選項是協助理解,不是替使用者做決策。
|
||||
|
||||
## 查證與提問邊界
|
||||
|
||||
- 能從輸入、既有議題或可取得的 repo 內容查證的事項,先自行查證。
|
||||
- 只有需要需求擁有者決策的事項才提問。
|
||||
- 不把架構、資料表、模組或其他實作方案當成需求答案;這些交給 `/sdlc-analyze`。
|
||||
- 不把 agent 的推測寫成使用者已確認的需求。
|
||||
|
||||
## 回答狀態
|
||||
|
||||
- 具體回答可填入工作稿,並重新檢查所有必要維度與情境。
|
||||
- 使用者明確確認「不適用」且提供原因,可標記該項已完成。
|
||||
- 未回答、拒絕回答、「不知道」或「尚未決定」都仍是缺口,不得填成已確認。
|
||||
- 仍有缺口時不得建立需求議題;應繼續一次問一題。
|
||||
|
||||
## 建立前完整性閘門
|
||||
|
||||
建立需求議題前必須確認:
|
||||
|
||||
- 九個段落都有實質內容,或使用者已確認不適用並說明原因。
|
||||
- 需求的角色、情境、行為與可觀察結果完整。
|
||||
- 主要成功情境已確認;適用的失敗與邊界情境已確認。
|
||||
- 沒有未回答或「尚未決定」的阻塞缺口。
|
||||
- 驗收標準描述外部可觀察結果,不綁定不必要的實作細節。
|
||||
- 內容前後一致,且非目標足以限制範圍。
|
||||
|
||||
通過閘門後才可套用需求議題模板、查標籤與建立議題。
|
||||
+2
-41
@@ -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], '領取');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* 安裝完成等於驗過能用。
|
||||
*
|
||||
* install 寫完轉接檔之後,這裡把那條叫用鏈真的走一遍:
|
||||
*
|
||||
* 轉接檔 → PATH 上的 tea-sdlc → AI Agent CLI runtime registry
|
||||
*
|
||||
* PATH 上的 tea-sdlc 仍會取回流程正本;runtime verifier 再依平台的安全 probe
|
||||
* 確認六個流程都出現在正確的本機 registry。沒有安全 probe 的平台回報 not-supported。
|
||||
* 最脆弱的是中間那一環。套件裝在某個 Node 版本底下,換個版本管理器或改 npm prefix
|
||||
* 就找不到了,而轉接檔本身看起來完全正常——使用者要到第一次打 /sdlc-plan 才發現,
|
||||
* 那時他已經離開安裝的心智狀態很久了。所以這裡不是「檢查檔案在不在」,而是真的到
|
||||
* PATH 上把 tea-sdlc 找出來執行一次,再把取回的正本跟套件裡的那一份逐字比對:
|
||||
* 找不到、叫不動、或叫到的是另一份安裝,三種都驗得出來。
|
||||
*
|
||||
* **完全不需要網路**:取正本是讀套件內的檔案,比對轉接檔是讀本機目錄。所以它無條件
|
||||
* 執行,不受 Gitea 登入或時間追蹤狀態影響。
|
||||
*
|
||||
* **失敗不回滾**,由呼叫端保留已經寫好的轉接檔。回滾在升級情境下是淨損失:使用者
|
||||
* 原本有一組能用的舊轉接檔,覆蓋後驗證失敗,回滾把新的刪掉、舊的也已經沒了,他從
|
||||
* 「有點舊但能用」變成什麼都沒有。而且最可能的病灶是「PATH 上找不到 tea-sdlc」,
|
||||
* 那不是轉接檔的問題,刪掉它一點幫助也沒有——所以報告把叫用鏈與轉接檔分開講。
|
||||
*/
|
||||
import { verifyRuntimePlatforms } from './runtime-verify.js';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { onPath } from './lib.js';
|
||||
|
||||
/** 轉接檔叫的就是這個名字。它同時是要到 PATH 上找的東西。 */
|
||||
const COMMAND = 'tea-sdlc';
|
||||
|
||||
/**
|
||||
* 轉接檔裡那一句叫用行——寫進去的跟等一下要驗的,是同一個函式算出來的。
|
||||
* 分成兩份寫的話,改了格式只會讓驗證從此永遠 fail,或者更糟:永遠 pass。
|
||||
* @param {string} name 指令名,例如 sdlc-plan
|
||||
* @param {string} version 產生這份轉接檔的套件版本
|
||||
* @returns {{command: string, args: string[], line: string}} line 是寫進轉接檔的字面
|
||||
*/
|
||||
export function invocation(name, version) {
|
||||
const args = ['prompt', '--name', name, '--adapter-version', version];
|
||||
return { command: COMMAND, args, line: `${COMMAND} ${args.join(' ')}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* 走一遍叫用鏈,逐平台回報 pass/fail。
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.version 這次安裝的套件版本
|
||||
* @param {{name: string, text: string}} options.prompt 要實際取回來比對的那一份正本
|
||||
* @param {{name: string, adapters: {name: string, path: string}[]}[]} options.platforms
|
||||
* 這次寫過的平台與它們的轉接檔
|
||||
* @returns {{ok: boolean, chain: object, platforms: object[]}}
|
||||
*/
|
||||
export function verifyInstall({ version, prompt, platforms }) {
|
||||
const chain = verifyChain(prompt, version);
|
||||
const runtime = verifyRuntimePlatforms(platforms);
|
||||
const reports = platforms.map((platform, index) => verifyPlatform(platform, version, runtime[index]));
|
||||
|
||||
return {
|
||||
ok: chain.ok && reports.every((report) => report.ok),
|
||||
chain,
|
||||
platforms: reports,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 中間那一環:PATH 上真的有一個 tea-sdlc,叫得動,而且叫到的就是這一份套件。
|
||||
*
|
||||
* 比對內容而不是只看它有沒有回 exit 0——機器上裝了不只一份 tea-sdlc 時,
|
||||
* 轉接檔叫到的會是 PATH 上排在前面的那一份,而它可能是舊版甚至別的專案。
|
||||
* 那種情況下每一支指令都跑得起來,只是跑的不是使用者剛裝的東西。
|
||||
*
|
||||
* @param {{name: string, text: string}} prompt 套件裡的那一份正本,逐字比對用
|
||||
* @param {string} version
|
||||
*/
|
||||
function verifyChain(prompt, version) {
|
||||
const { command, args, line } = invocation(prompt.name, version);
|
||||
const resolved = onPath(command);
|
||||
const 報告 = { command, resolved, prompt: prompt.name, line };
|
||||
|
||||
if (resolved === null) {
|
||||
return {
|
||||
...報告,
|
||||
ok: false,
|
||||
病灶: `PATH 上找不到 ${command},所以轉接檔裡的「${line}」叫不動`,
|
||||
修復:
|
||||
`這不是轉接檔的問題,刪掉它沒有幫助。多半是套件裝在另一個 Node 版本底下:` +
|
||||
`切回安裝時用的那個版本,或重跑 npm i -g(裝好後 \`command -v ${command}\` 要找得到)`,
|
||||
};
|
||||
}
|
||||
|
||||
let 取回;
|
||||
try {
|
||||
// stdio 要指名 pipe。不指名的話 execFileSync 預設會把子行程的 stderr 直接接到我們的
|
||||
// stderr,破壞「stderr 永遠保持乾淨,呼叫端只需要讀 stdout」那條輸出契約——
|
||||
// 而且我們要的正是把它收進 error.stderr 當成病灶講出來。
|
||||
取回 = execFileSync(resolved, args, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
...報告,
|
||||
ok: false,
|
||||
病灶: `${resolved} 叫得到但跑不完:${抱怨(error)}`,
|
||||
修復: `直接跑一次 \`${line}\` 看完整訊息;裝壞了就重跑 npm i -g 把套件蓋回去`,
|
||||
};
|
||||
}
|
||||
|
||||
if (取回 !== prompt.text) {
|
||||
return {
|
||||
...報告,
|
||||
ok: false,
|
||||
病灶:
|
||||
`${resolved} 取回的 ${prompt.name} 正本與這一份套件裡的不一樣,` +
|
||||
'轉接檔叫到的是另一份 tea-sdlc',
|
||||
修復:
|
||||
`機器上裝了不只一份,或 PATH 指到舊的那一份:\`command -v ${command}\` 看它指到哪,` +
|
||||
'把不要的那一份移除後重跑 tea-sdlc install',
|
||||
};
|
||||
}
|
||||
|
||||
return { ...報告, ok: true, 病灶: null, 修復: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* 跑不完的子行程到底在抱怨什麼。
|
||||
*
|
||||
* 先看 stdout。tea-sdlc 自己的失敗一律是 stdout 上的一行 JSON(見 lib 的 main 與 write,
|
||||
* 「stderr 永遠保持乾淨」),所以真正有用的 code 與 message 在那裡;只讀 stderr 的話,
|
||||
* 病灶會退化成沒有資訊的「Command failed: …」,使用者還是不知道哪裡壞了。
|
||||
*
|
||||
* 讀不到就退回 stderr——那是「根本不是 tea-sdlc」的情況,例如同名的別的東西,
|
||||
* 或殼底下的 node 不見了,那種東西的抱怨只會出現在 stderr。
|
||||
* @param {Error} error execFileSync 丟出來的錯
|
||||
* @returns {string} 給人看的一句話
|
||||
*/
|
||||
function 抱怨(error) {
|
||||
try {
|
||||
const { error: 內層 } = JSON.parse(String(error.stdout).trim().split('\n').at(-1));
|
||||
if (內層?.message) return `${內層.code} ${內層.message}`;
|
||||
} catch {
|
||||
// stdout 不是我們的 JSON envelope,往下退
|
||||
}
|
||||
return (String(error.stderr ?? '').trim() || error.message || '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 把驗證結果收成一句話:病灶在哪、怎麼修。
|
||||
*
|
||||
* 報告的形狀由這裡產生,講法就留在同一個模組裡:install 只負責把這句話放進 envelope,
|
||||
* 不必知道 chain 與 platforms 底下長什麼樣。只讀 error.message 的呼叫端(包括終端機前面
|
||||
* 的使用者)光看這一句就該知道下一步做什麼。
|
||||
* @param {ReturnType<typeof verifyInstall>} verify
|
||||
* @returns {string}
|
||||
*/
|
||||
export function 診斷(verify) {
|
||||
const 壞掉的 = [
|
||||
...(verify.chain.ok ? [] : [verify.chain]),
|
||||
...verify.platforms.flatMap((platform) => platform.failures),
|
||||
];
|
||||
|
||||
return [
|
||||
'轉接檔已經寫好,但驗不過——它們留著沒有刪,修好病灶之後重跑一次就好。',
|
||||
...壞掉的.map((failure) => `${failure.病灶};${failure.修復}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 一個平台的轉接檔:每一份都要在,而且內容要含正確的叫用行。
|
||||
*
|
||||
* 讀回磁碟上的內容而不是相信剛才寫出去的字串:這一步要驗的正是「寫出去之後檔案
|
||||
* 真的長那樣」,拿記憶體裡的原稿來比等於自己驗自己。
|
||||
*/
|
||||
function verifyPlatform(platform, version, runtime) {
|
||||
const adapterFailures = platform.adapters
|
||||
.map((adapter) => checkAdapter(adapter, version))
|
||||
.filter((failure) => failure !== null);
|
||||
const failures = [...adapterFailures, ...runtime.failures];
|
||||
|
||||
return {
|
||||
name: platform.name,
|
||||
ok: failures.length === 0,
|
||||
status: runtime.status,
|
||||
checked: platform.adapters.length,
|
||||
failures,
|
||||
runtime,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{path: string, 病灶: string, 修復: string}|null} 沒問題時回 null
|
||||
*/
|
||||
function checkAdapter({ name, path }, version) {
|
||||
const 重裝 = '重跑 tea-sdlc install 把它蓋回去';
|
||||
|
||||
if (!existsSync(path)) {
|
||||
return { path, 病灶: `找不到 ${name} 的轉接檔`, 修復: 重裝 };
|
||||
}
|
||||
|
||||
const { line } = invocation(name, version);
|
||||
if (!readFileSync(path, 'utf8').includes(line)) {
|
||||
return {
|
||||
path,
|
||||
病灶: `轉接檔裡沒有正確的叫用行「${line}」,讀到它的助理不會知道要執行什麼`,
|
||||
修復: `這份檔案被改過或是別的東西產生的;確認沒有自己要留的內容之後,${重裝}`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+82
-11
@@ -22,6 +22,7 @@ import {
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import {
|
||||
Failure,
|
||||
ScriptError,
|
||||
checkPluginLayout,
|
||||
missingBinaries,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
parseFlags,
|
||||
promptsDir,
|
||||
} from './lib.js';
|
||||
import { invocation, verifyInstall, 診斷 } from './install-verify.js';
|
||||
|
||||
/**
|
||||
* 七個平台。`detect` 是「這台機器裝了它沒有」的判準,`target` 是轉接檔的落點,
|
||||
@@ -45,7 +47,7 @@ const PLATFORMS = [
|
||||
{ name: 'claude', label: 'Claude Code', base: 'home', detect: ['.claude'], target: ['.claude', 'commands'], kind: 'command' },
|
||||
{ name: 'codex', label: 'Codex', base: 'home', detect: ['.codex'], target: ['.codex', 'prompts'], kind: 'command' },
|
||||
{ name: 'opencode', label: 'OpenCode', base: 'home', detect: ['.config', 'opencode'], target: ['.config', 'opencode', 'command'], kind: 'command' },
|
||||
{ name: 'oh-my-pi', label: 'oh-my-pi', base: 'home', detect: ['.omp'], target: ['.omp', 'commands'], kind: 'command' },
|
||||
{ name: 'oh-my-pi', label: 'oh-my-pi', base: 'home', detect: ['.omp'], target: ['.omp', 'agent', 'commands'], kind: 'command' },
|
||||
{ name: 'antigravity', label: 'Antigravity', base: 'home', detect: ['.gemini'], target: ['.gemini', 'skills'], kind: 'skill' },
|
||||
{ name: 'kiro', label: 'Kiro', base: 'home', detect: ['.kiro'], target: ['.kiro', 'skills'], kind: 'skill' },
|
||||
{ name: 'copilot', label: 'GitHub Copilot', base: 'cwd', detect: ['.github'], target: ['.github', 'skills'], kind: 'skill' },
|
||||
@@ -102,28 +104,54 @@ export function runInstall(argv) {
|
||||
const version = packageVersion();
|
||||
const chosen = choose(flags.platform);
|
||||
|
||||
const platforms = chosen.map((platform) => {
|
||||
const dryRun = flags['dry-run'] === true;
|
||||
|
||||
const written = chosen.map((platform) => {
|
||||
const files = prompts.map((prompt) => ({
|
||||
name: prompt.name,
|
||||
path: adapterPath(platform, prompt.name),
|
||||
text: adapterText(platform, prompt, version),
|
||||
}));
|
||||
if (!flags['dry-run']) {
|
||||
if (!dryRun) {
|
||||
for (const file of files) {
|
||||
mkdirSync(dirname(file.path), { recursive: true });
|
||||
writeFileSync(file.path, file.text);
|
||||
}
|
||||
}
|
||||
return { name: platform.name, kind: platform.kind, adapters: files.map((file) => file.path) };
|
||||
return { name: platform.name, kind: platform.kind, files };
|
||||
});
|
||||
|
||||
return {
|
||||
dryRun: flags['dry-run'] === true,
|
||||
const verify = dryRun
|
||||
? { skipped: true, reason: '--dry-run 沒有寫入任何轉接檔,沒有東西可以驗' }
|
||||
: verifyInstall({
|
||||
version,
|
||||
// 取一份就夠了:要驗的是這條鏈通不通,不是每一份正本的內容
|
||||
prompt: { name: prompts[0].name, text: prompts[0].text },
|
||||
// 刻意只交出 name 與 path,不交 text:驗證要驗的正是「寫出去之後檔案真的長那樣」,
|
||||
// 把剛才那份原稿也遞過去,它就有機會拿記憶體裡的字串來比,等於自己驗自己
|
||||
platforms: written.map(({ name, files }) => ({
|
||||
name,
|
||||
adapters: files.map(({ name: 指令, path }) => ({ name: 指令, path })),
|
||||
})),
|
||||
});
|
||||
|
||||
const data = {
|
||||
dryRun,
|
||||
version,
|
||||
commands: prompts.map((prompt) => prompt.name),
|
||||
platforms,
|
||||
platforms: written.map(({ name, kind, files }) => ({
|
||||
name,
|
||||
kind,
|
||||
adapters: files.map((file) => file.path),
|
||||
})),
|
||||
missingBinaries: missing,
|
||||
warning: hint === '' ? null : hint,
|
||||
verify,
|
||||
};
|
||||
|
||||
// 驗不過就回失敗,但轉接檔一份都不刪:見 install-verify 開頭對「失敗不回滾」的交代。
|
||||
// data 照樣交出去,使用者才看得到已經寫了哪些、以及是哪一段不通。
|
||||
return verify.skipped || verify.ok ? data : new Failure('INSTALL_VERIFY_FAILED', 診斷(verify), data);
|
||||
}
|
||||
|
||||
|
||||
@@ -192,7 +220,48 @@ export function platformReport() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 供 verify 使用的唯讀目標。它只看目前偵測到的平台,不進互動選擇,也不寫檔。
|
||||
* @param {string|undefined} flag --platform 的值
|
||||
* @returns {{name: string, adapters: {name: string, path: string}[]}[]}
|
||||
*/
|
||||
export function verificationTargets(flag) {
|
||||
const detected = PLATFORMS.filter((platform) => existsSync(pathOf(platform, platform.detect)));
|
||||
const chosen = flag === undefined
|
||||
? detected
|
||||
: (() => {
|
||||
const names = parsePlatformFlag(flag);
|
||||
const missing = names.filter((name) => !detected.some((platform) => platform.name === name));
|
||||
if (missing.length > 0) {
|
||||
throw new ScriptError(
|
||||
'PLATFORM_NOT_DETECTED',
|
||||
`這台機器上偵測不到 ${missing.join('、')};請先把該平台裝起來再重跑`,
|
||||
);
|
||||
}
|
||||
return PLATFORMS.filter((platform) => names.includes(platform.name));
|
||||
})();
|
||||
|
||||
if (chosen.length === 0) {
|
||||
throw new ScriptError(
|
||||
'NO_PLATFORM_DETECTED',
|
||||
`偵測不到任何 agent 平台(找過 ${PLATFORMS.map(detectLabel).join('、')});請先安裝其中至少一個`,
|
||||
);
|
||||
}
|
||||
|
||||
const names = readPrompts().map((prompt) => prompt.name);
|
||||
return chosen.map((platform) => ({
|
||||
name: platform.name,
|
||||
adapters: names.map((name) => ({ name, path: adapterPath(platform, name) })),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** @returns {{name: string, text: string}} */
|
||||
export function verificationPrompt() {
|
||||
const prompt = readPrompts()[0];
|
||||
return { name: prompt.name, text: prompt.text };
|
||||
}
|
||||
// ── 平台挑選 ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -331,8 +400,7 @@ function adapterText(platform, prompt, version) {
|
||||
`<!-- ${MARKER} v${version}:由 tea-sdlc install 產生,請勿手動編輯。`,
|
||||
' 改流程請改流程正本(不必重裝);指令數量變了才需要重跑 tea-sdlc install。 -->',
|
||||
'',
|
||||
`執行 \`tea-sdlc prompt --name ${prompt.name} --adapter-version ${version}\`,` +
|
||||
'並完全遵照它印出的內容執行。',
|
||||
`執行 \`${invocation(prompt.name, version).line}\`,並完全遵照它印出的內容執行。`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -365,7 +433,10 @@ function adapterVersion(path) {
|
||||
/**
|
||||
* 有哪些指令可以裝。以 prompts/ 裡實際存在的正本為準,不是寫死的六個名字——
|
||||
* 裝出一個指向不存在正本的轉接檔,使用者只會看到 PROMPT_NOT_FOUND。
|
||||
* @returns {{name: string, description: string}[]}
|
||||
*
|
||||
* 連 text 一起帶出來,是因為驗證要拿它跟「PATH 上的 tea-sdlc 取回來的那一份」逐字比對。
|
||||
* 那邊讀的是同一個檔案、同樣的 utf8,所以兩邊本來就該一字不差。
|
||||
* @returns {{name: string, description: string, text: string}[]}
|
||||
*/
|
||||
function readPrompts() {
|
||||
checkPluginLayout();
|
||||
@@ -392,7 +463,7 @@ function readPrompts() {
|
||||
`流程正本 ${entry} 的 description 必須以「${prefix}」起頭,目前是:${description}`,
|
||||
);
|
||||
}
|
||||
return { name, description };
|
||||
return { name, description, text };
|
||||
});
|
||||
|
||||
if (prompts.length === 0) {
|
||||
|
||||
@@ -182,6 +182,19 @@ export function referencedIndex(sections, name, label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作包掛在哪一顆需求議題底下:關聯段落的 `需求議題:#7`。
|
||||
*
|
||||
* 這是「這顆議題是不是工作包、屬於誰」的唯一判準,抽取(wp-extract)與清單(wp-list)
|
||||
* 共用同一個函式。兩邊各寫一次也跑得起來,但歸屬規則一旦有兩份,某天只會有一邊被改到,
|
||||
* 而分岔的樣子是「清單裡看得到、抽取卻說不是」——那種不一致沒有人看得懂。
|
||||
* @param {Map<string, string>} sections
|
||||
* @returns {number|null} 沒填或不是工作包時為 null
|
||||
*/
|
||||
export function requirementIndex(sections) {
|
||||
return referencedIndex(sections, '關聯', '需求議題');
|
||||
}
|
||||
|
||||
/**
|
||||
* 在段落裡找出「標籤:數字」那一行的數字,例如關聯段落的 `估算人天:3`。
|
||||
* 與 referencedIndex 同形狀,差別只在這裡要的是數量而非議題編號,所以認小數。
|
||||
|
||||
@@ -31,16 +31,15 @@ main(async () => {
|
||||
const repo = parseRepo(flags.repo);
|
||||
const index = parseIndex(flags.index);
|
||||
const issuePath = `/repos/${repo}/issues/${index}`;
|
||||
const commentsPath = `${issuePath}/comments`;
|
||||
const timelinePath = `${issuePath}/timeline`;
|
||||
|
||||
if (flags['dry-run']) {
|
||||
return {
|
||||
dryRun: true,
|
||||
repo,
|
||||
index,
|
||||
requests: [
|
||||
{ method: 'GET', path: issuePath },
|
||||
{ method: 'GET', path: commentsPath },
|
||||
{ method: 'GET', path: timelinePath },
|
||||
],
|
||||
note: UNMERGED_COMMENT_NOTE,
|
||||
};
|
||||
@@ -62,7 +61,7 @@ main(async () => {
|
||||
目標: listSection(sections, '目標'),
|
||||
非目標: listSection(sections, '非目標'),
|
||||
名詞表: tableSection(sections, '領域名詞表'),
|
||||
流程圖: textSection(sections, '流程圖'),
|
||||
文件: textSection(sections, '文件'),
|
||||
驗收標準: listSection(sections, '驗收標準'),
|
||||
影響範圍: listSection(sections, '影響範圍'),
|
||||
未決事項: listSection(sections, '未決事項'),
|
||||
|
||||
+68
-65
@@ -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) {
|
||||
@@ -217,11 +217,35 @@ export class RawText {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 失敗,但手上的東西還是要交出去。
|
||||
*
|
||||
* 丟 ScriptError 的失敗只剩 code 與 message,因為那種失敗通常是「什麼都還沒做」。
|
||||
* 有一種失敗不是這樣:事情做完了、檔案也寫出去了,只是驗不過。那時使用者最需要
|
||||
* 知道的正是「已經寫了哪些、哪一個平台不通」,把 data 丟掉等於逼他自己去翻。
|
||||
*
|
||||
* envelope 形狀不變,只是 {ok:false, error} 旁邊多一個 data:只讀 error.code 的
|
||||
* 呼叫端照常運作。
|
||||
*/
|
||||
export class Failure {
|
||||
/**
|
||||
* @param {string} code 可區分的錯誤碼
|
||||
* @param {string} message 給人看的訊息,要說得出病灶與修復方式
|
||||
* @param {object} data 已經做完的部分,原樣放進 envelope
|
||||
*/
|
||||
constructor(code, message, data) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每支腳本與指令入口的進入點:跑完印一行 JSON 就結束,例外一律收斂成 {ok:false}。
|
||||
* stderr 永遠保持乾淨,呼叫端只需要讀 stdout。
|
||||
* 回傳 RawText 時改印原樣內容,不包 envelope,其餘行為不變。
|
||||
* @param {() => Promise<object|RawText>|object|RawText} run 回傳要放進 data 的物件
|
||||
* 回傳 RawText 時改印原樣內容,不包 envelope;回傳 Failure 時印 {ok:false} 並退出碼 1,
|
||||
* 但把 data 一起帶出去。其餘行為不變。
|
||||
* @param {() => Promise<object|RawText|Failure>|object|RawText|Failure} run 回傳要放進 data 的物件
|
||||
*/
|
||||
export async function main(run) {
|
||||
try {
|
||||
@@ -230,6 +254,11 @@ export async function main(run) {
|
||||
write(data.text, 0);
|
||||
return;
|
||||
}
|
||||
if (data instanceof Failure) {
|
||||
const { code, message } = data;
|
||||
write(`${JSON.stringify({ ok: false, error: { code, message }, data: data.data })}\n`, 1);
|
||||
return;
|
||||
}
|
||||
write(`${JSON.stringify({ ok: true, data })}\n`, 0);
|
||||
} catch (error) {
|
||||
const code = error instanceof ScriptError ? error.code : 'UNEXPECTED';
|
||||
@@ -393,6 +422,31 @@ export async function giteaRequest(login, method, path, { body, query } = {}) {
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? safeJson(text) : null };
|
||||
}
|
||||
/**
|
||||
* 上傳 Gitea issue attachment。這是唯一的 multipart HTTP 出口。
|
||||
* @param {{base:string, token:string}} login
|
||||
* @param {string} path
|
||||
* @param {string} filePath
|
||||
* @param {string} fileName
|
||||
* @returns {Promise<{status:number, body:any}>}
|
||||
*/
|
||||
export async function giteaUpload(login, path, filePath, fileName) {
|
||||
const form = new FormData();
|
||||
form.append('attachment', new Blob([readFileSync(filePath)]), fileName);
|
||||
const url = new URL(`${login.base}${path}`);
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `token ${login.token}`, Accept: 'application/json' },
|
||||
body: form,
|
||||
});
|
||||
} catch (cause) {
|
||||
throw new ScriptError('NETWORK_ERROR', `連不上 Gitea(POST ${path}):${cause.message}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: safeJson(text) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「非預期狀態碼」收斂成帶狀態碼的錯誤,成功則回傳 body。
|
||||
@@ -722,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 };
|
||||
}
|
||||
|
||||
@@ -842,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 指定的文字檔。
|
||||
@@ -930,16 +974,22 @@ export async function countUnmergedComments(login, repo, index, me) {
|
||||
/**
|
||||
* 逐頁走過一顆議題(或 PR)的一般留言。
|
||||
* 三支腳本都要做這件事:數未整併的則數、列出留言內容、核對 --merged 的 id。
|
||||
* Gitea 的 comments 端點固定只回第一批,完整清單改從 timeline 取得。
|
||||
* @returns {AsyncGenerator<object>} 一則一則交出去
|
||||
*/
|
||||
export async function* listIssueComments(login, repo, index) {
|
||||
const path = `/repos/${repo}/issues/${index}/comments`;
|
||||
const path = `/repos/${repo}/issues/${index}/timeline`;
|
||||
const seen = new Set();
|
||||
|
||||
for await (const comments of pages(login, path, {
|
||||
limitCode: 'COMMENT_LIMIT',
|
||||
limitHint: `${path} 的留言太多,讀不完整份清單`,
|
||||
for await (const entries of pages(login, path, {
|
||||
limitCode: 'TIMELINE_LIMIT',
|
||||
limitHint: `${path} 的 timeline 太多,讀不完整份留言清單`,
|
||||
})) {
|
||||
for (const comment of comments) yield comment;
|
||||
for (const entry of entries) {
|
||||
if (entry.type !== 'comment' || seen.has(entry.id)) continue;
|
||||
seen.add(entry.id);
|
||||
yield entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -959,53 +1009,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
|
||||
);
|
||||
}
|
||||
|
||||
// ── 標籤 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Regular → Executable
+18
-215
@@ -1,79 +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,
|
||||
} 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), {
|
||||
@@ -82,157 +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, stopPath);
|
||||
|
||||
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 自己能重現的' +
|
||||
'手動驗證步驟。「已測試通過」這種寫法看不出跑過什麼,等於沒寫',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「這顆議題上沒有碼錶在跑」的回法不只一種:看過 500,也看過 409。
|
||||
* 狀態碼隨站台版本而異,所以認的是「狀態碼在這一組裡 **且** 訊息說的是碼錶」——
|
||||
* 只看訊息會把真的伺服器錯誤一起吞掉,只看狀態碼會把別的衝突也當成沒錶。
|
||||
*/
|
||||
const NO_STOPWATCH_STATUS = [409, 500];
|
||||
|
||||
/**
|
||||
* 停錶。錶沒在跑時不算失敗——PR 已經開出去了,把整件事報成失敗只會讓人以為
|
||||
* PR 沒開成而重跑一次。
|
||||
*/
|
||||
async function stopStopwatch(login, path) {
|
||||
const response = await giteaRequest(login, 'POST', path, { body: {} });
|
||||
if (response.status >= 200 && response.status < 300) return true;
|
||||
|
||||
if (
|
||||
NO_STOPWATCH_STATUS.includes(response.status) &&
|
||||
/stopwatch/i.test(response.body?.message ?? '')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
expectOk(response, `POST ${path}`);
|
||||
return false;
|
||||
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;
|
||||
}
|
||||
|
||||
+3
-8
@@ -24,6 +24,7 @@ import {
|
||||
ScriptError,
|
||||
expectOk,
|
||||
giteaRequest,
|
||||
listIssueComments,
|
||||
main,
|
||||
pages,
|
||||
parseFlags,
|
||||
@@ -205,14 +206,8 @@ async function findInline(login, repo, index, commentId) {
|
||||
}
|
||||
|
||||
async function findGeneral(login, repo, index, commentId) {
|
||||
const path = `/repos/${repo}/issues/${index}/comments`;
|
||||
|
||||
for await (const comments of pages(login, path, {
|
||||
limitCode: 'COMMENT_LIMIT',
|
||||
limitHint: `${path} 的留言太多,找不完`,
|
||||
})) {
|
||||
const hit = comments.find((comment) => comment.id === commentId);
|
||||
if (hit) return hit;
|
||||
for await (const comment of listIssueComments(login, repo, index)) {
|
||||
if (comment.id === commentId) return comment;
|
||||
}
|
||||
throw notFound(index, '一般留言', commentId);
|
||||
}
|
||||
|
||||
@@ -49,9 +49,8 @@ export function plannedRequests(repo, index) {
|
||||
return [
|
||||
{ method: 'GET', path: '/user' },
|
||||
{ method: 'GET', path: pullPath },
|
||||
{ method: 'GET', path: `/repos/${repo}/issues/${index}/comments` },
|
||||
{ method: 'GET', path: `${pullPath}/reviews` },
|
||||
{ method: 'GET', path: `/repos/${repo}/issues/${index}/timeline` },
|
||||
{ method: 'GET', path: `${pullPath}/reviews` },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -65,7 +64,7 @@ export function plannedRequests(repo, index) {
|
||||
export function commonRequests(repo, index) {
|
||||
return [
|
||||
{ method: 'GET', path: `/repos/${repo}/issues/${index}` },
|
||||
{ method: 'GET', path: `/repos/${repo}/issues/${index}/comments` },
|
||||
{ method: 'GET', path: `/repos/${repo}/issues/${index}/timeline` },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Regular → Executable
+8
-374
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { onPath } from './lib.js';
|
||||
|
||||
/** Runtime probes are deliberately local, non-interactive, and network-free. */
|
||||
const SPECS = {
|
||||
'oh-my-pi': { binary: 'omp', supported: true, root: (home) => join(home, '.omp', 'agent', 'commands') },
|
||||
claude: { binary: 'claude', supported: true, root: (home) => join(home, '.claude', 'commands') },
|
||||
codex: { binary: 'codex', supported: true, root: (home) => join(home, '.codex', 'prompts') },
|
||||
opencode: { binary: 'opencode', supported: true, root: (home) => join(home, '.config', 'opencode', 'command') },
|
||||
antigravity: { binary: 'gemini', supported: false },
|
||||
kiro: { binary: 'kiro', supported: false },
|
||||
copilot: { binary: 'gh', supported: false },
|
||||
};
|
||||
|
||||
const PROBE_VERSION = 1;
|
||||
|
||||
/**
|
||||
* @param {{name: string, adapters: {name: string, path: string}[]}[]} platforms
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function verifyRuntimePlatforms(platforms) {
|
||||
return platforms.map((platform) => verifyRuntimePlatform(platform));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{name: string, adapters: {name: string, path: string}[]}} platform
|
||||
* @returns {object}
|
||||
*/
|
||||
function verifyRuntimePlatform(platform) {
|
||||
const spec = SPECS[platform.name];
|
||||
if (!spec || !spec.supported) {
|
||||
return {
|
||||
name: platform.name,
|
||||
status: 'not-supported',
|
||||
probe: { command: spec?.binary ?? null, args: [], network: false, interactive: false, version: PROBE_VERSION },
|
||||
resolved: spec ? onPath(spec.binary) : null,
|
||||
commands: [],
|
||||
missing: [],
|
||||
failures: [],
|
||||
remediation: '此 AI Agent CLI 沒有可供 tea-sdlc 安全呼叫的 command registry probe;請在該 CLI 內手動確認指令。',
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = onPath(spec.binary);
|
||||
const probe = {
|
||||
command: 'filesystem-registry',
|
||||
args: [spec.binary],
|
||||
network: false,
|
||||
interactive: false,
|
||||
version: PROBE_VERSION,
|
||||
};
|
||||
if (resolved === null) {
|
||||
return unsupportedRuntime(
|
||||
platform,
|
||||
probe,
|
||||
null,
|
||||
`找不到 ${spec.binary},沒有可安全執行的 runtime probe`,
|
||||
`請安裝 ${spec.binary} 並把它加入 PATH 後再重跑 tea-sdlc verify`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const root = dirname(platform.adapters[0]?.path ?? spec.root(process.env.HOME || homedir()));
|
||||
const expected = platform.adapters.map(({ name }) => name);
|
||||
const available = new Set(
|
||||
existsSync(root)
|
||||
? readdirSync(root, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
||||
.map((entry) => basename(entry.name, '.md'))
|
||||
: [],
|
||||
);
|
||||
const commands = expected.filter((name) => available.has(name));
|
||||
const missing = expected.filter((name) => !available.has(name));
|
||||
if (missing.length > 0) {
|
||||
return failedRuntime(
|
||||
platform,
|
||||
{ ...probe, registry: root },
|
||||
resolved,
|
||||
`runtime registry 找不到:${missing.join('、')}`,
|
||||
`確認 ${root} 是 ${spec.binary} 的 command registry,然後重跑 tea-sdlc install`,
|
||||
commands,
|
||||
missing,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name: platform.name,
|
||||
status: 'pass',
|
||||
probe: { ...probe, registry: root },
|
||||
resolved,
|
||||
commands,
|
||||
missing,
|
||||
failures: [],
|
||||
remediation: null,
|
||||
};
|
||||
}
|
||||
|
||||
function unsupportedRuntime(platform, probe, resolved, message, remediation) {
|
||||
return {
|
||||
name: platform.name,
|
||||
status: 'not-supported',
|
||||
probe,
|
||||
resolved,
|
||||
commands: [],
|
||||
missing: platform.adapters.map(({ name }) => name),
|
||||
failures: [],
|
||||
remediation: `${message};${remediation}`,
|
||||
};
|
||||
}
|
||||
|
||||
function failedRuntime(platform, probe, resolved, message, remediation, commands = [], missing = platform.adapters.map(({ name }) => name)) {
|
||||
const failure = {
|
||||
path: probe.registry ?? null,
|
||||
病灶: message,
|
||||
修復: remediation,
|
||||
};
|
||||
return {
|
||||
name: platform.name,
|
||||
status: 'fail',
|
||||
probe,
|
||||
resolved,
|
||||
commands,
|
||||
missing,
|
||||
failures: [failure],
|
||||
remediation,
|
||||
};
|
||||
}
|
||||
|
||||
/** @returns {number} */
|
||||
export function runtimeProbeVersion() {
|
||||
return PROBE_VERSION;
|
||||
}
|
||||
+269
-35
@@ -1,50 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 依相依關係推算每顆工作包的截止日。
|
||||
* 依工作日與相依關係計算工作包的日期、CPM 與 PERT。
|
||||
*
|
||||
* 保證一件事:任一工作包的截止日都不早於它的先決工作包。人工排時程最常出現的
|
||||
* 矛盾就是前置工作比後續還晚到期,看板上看起來合理、實際上做不到。
|
||||
*
|
||||
* 這支腳本不碰 Gitea 也不碰 git,純算數字,所以沒有前置檢查、也不需要登入。
|
||||
* 算出來的日期交給 issue-update 逐顆寫上去。
|
||||
*
|
||||
* 日期以「日曆日」累加,不跳週末也不扣假日——跳過哪些日子是團隊政策,
|
||||
* 這裡不替使用者決定。
|
||||
* 政府辦公日曆只影響日期,不影響人天估算;取不到某年度資料時仍會跳過週末,
|
||||
* 並把警告寫到 stderr。輸出的日期採結束日不含在工期內,延續原本 dueDate 契約。
|
||||
*
|
||||
* 用法:node scripts/schedule.js --plan-file <計畫檔>
|
||||
*
|
||||
* 計畫檔格式:
|
||||
* {
|
||||
* "startDate": "2026-09-21",
|
||||
* "startDate": "2026-09-22",
|
||||
* "workPackages": [
|
||||
* { "index": 12, "title": "建立抽取契約", "days": 3, "depends": [11] }
|
||||
* { "index": 12, "title": "建立抽取契約", "days": 3, "depends": [11] },
|
||||
* {
|
||||
* "index": 13, "title": "驗證", "optimistic": 2,
|
||||
* "mostLikely": 3, "pessimistic": 5, "depends": [12]
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { ScriptError, main, parseFlags } from './lib.js';
|
||||
|
||||
const CALENDAR_METADATA_URL = 'https://data.gov.tw/api/front/dataset/detail?nid=14718';
|
||||
const CALENDAR_URL_OVERRIDE = 'TEA_SDLC_CALENDAR_URL';
|
||||
const calendarCache = new Map();
|
||||
|
||||
main(async () => {
|
||||
const flags = parseFlags(process.argv.slice(2), { required: ['plan-file'] });
|
||||
const plan = readPlan(flags['plan-file']);
|
||||
|
||||
const order = topologicalOrder(plan.workPackages);
|
||||
const dueByIndex = new Map();
|
||||
const schedule = [];
|
||||
const estimates = new Map(plan.workPackages.map((wp) => [wp.index, pertFor(wp)]));
|
||||
const holidays = await loadHolidays(plan, order, estimates);
|
||||
const schedule = buildSchedule(plan, order, estimates, holidays);
|
||||
|
||||
for (const index of order) {
|
||||
const wp = plan.workPackages.find((item) => item.index === index);
|
||||
// 從所有先決裡最晚的那一個接著做;沒有先決就從起始日開始
|
||||
const readyFrom = (wp.depends ?? []).reduce(
|
||||
(latest, dep) => (dueByIndex.get(dep) > latest ? dueByIndex.get(dep) : latest),
|
||||
plan.startDate,
|
||||
);
|
||||
const dueDate = addDays(readyFrom, wp.days);
|
||||
dueByIndex.set(index, dueDate);
|
||||
schedule.push({ index, title: wp.title, days: wp.days, dueDate });
|
||||
}
|
||||
|
||||
return { startDate: plan.startDate, order, schedule };
|
||||
return {
|
||||
startDate: plan.startDate,
|
||||
order,
|
||||
schedule: schedule.items,
|
||||
criticalPath: schedule.criticalPath,
|
||||
project: schedule.project,
|
||||
};
|
||||
});
|
||||
|
||||
function readPlan(planFile) {
|
||||
@@ -59,7 +55,7 @@ function readPlan(planFile) {
|
||||
throw new ScriptError('BAD_PLAN', `${planFile} 不是合法的 JSON:${error.message}`);
|
||||
}
|
||||
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(plan.startDate ?? '')) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(plan.startDate ?? '') || !validDate(plan.startDate)) {
|
||||
throw new ScriptError('BAD_PLAN', `startDate 需為 YYYY-MM-DD,收到的是 ${plan.startDate}`);
|
||||
}
|
||||
if (!Array.isArray(plan.workPackages) || plan.workPackages.length === 0) {
|
||||
@@ -68,8 +64,7 @@ function readPlan(planFile) {
|
||||
|
||||
const seen = new Set();
|
||||
for (const wp of plan.workPackages) {
|
||||
// 同一個 index 出現兩次時,相依看的是後者、標題與人天卻取到前者,
|
||||
// 算出來的時程會是兩份定義混出來的東西,而且完全不會報錯
|
||||
// 同一個 index 出現兩次時,相依看的是後者、標題與人天卻取到前者。
|
||||
if (seen.has(wp.index)) {
|
||||
throw new ScriptError('BAD_PLAN', `工作包 #${wp.index} 在計畫裡出現了不只一次`);
|
||||
}
|
||||
@@ -78,16 +73,48 @@ function readPlan(planFile) {
|
||||
if (!Number.isInteger(wp.index) || wp.index <= 0) {
|
||||
throw new ScriptError('BAD_PLAN', `工作包的 index 需為正整數,收到的是 ${wp.index}`);
|
||||
}
|
||||
if (!Number.isFinite(wp.days) || wp.days <= 0) {
|
||||
if (
|
||||
wp.depends !== undefined &&
|
||||
(!Array.isArray(wp.depends) || wp.depends.some((dep) => !Number.isInteger(dep)))
|
||||
) {
|
||||
throw new ScriptError('BAD_PLAN', `工作包 #${wp.index} 的 depends 需為整數陣列`);
|
||||
}
|
||||
const estimate = pertFor(wp);
|
||||
if (estimate === null && (!Number.isFinite(wp.days) || wp.days <= 0)) {
|
||||
throw new ScriptError('BAD_PLAN', `工作包 #${wp.index} 的 days 需為正數,收到的是 ${wp.days}`);
|
||||
}
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
function pertFor(wp) {
|
||||
const source = wp.pert ?? wp.estimates ?? wp;
|
||||
const names = [
|
||||
['optimistic', 'mostLikely', 'pessimistic'],
|
||||
['optimisticDays', 'mostLikelyDays', 'pessimisticDays'],
|
||||
['o', 'm', 'p'],
|
||||
];
|
||||
const fields = names.find(([o, m, p]) => [o, m, p].some((name) => source[name] !== undefined));
|
||||
if (!fields) return null;
|
||||
|
||||
const [optimistic, mostLikely, pessimistic] = fields.map((name) => Number(source[name]));
|
||||
if (
|
||||
![optimistic, mostLikely, pessimistic].every((value) => Number.isFinite(value) && value > 0) ||
|
||||
optimistic > mostLikely ||
|
||||
mostLikely > pessimistic
|
||||
) {
|
||||
throw new ScriptError(
|
||||
'BAD_PLAN',
|
||||
`工作包 #${wp.index} 的 PERT 估算需為正數且符合 optimistic ≤ mostLikely ≤ pessimistic`,
|
||||
);
|
||||
}
|
||||
const expected = (optimistic + 4 * mostLikely + pessimistic) / 6;
|
||||
const variance = ((pessimistic - optimistic) / 6) ** 2;
|
||||
return { optimistic, mostLikely, pessimistic, expected, variance };
|
||||
}
|
||||
|
||||
/**
|
||||
* 拓撲排序:先決一定排在後續之前。
|
||||
* 用 Kahn 演算法——排不完就代表有環,而環上的成員正是排不進去的那些。
|
||||
* 拓撲排序:先決一定排在後續之前。排不完就代表有環。
|
||||
*/
|
||||
function topologicalOrder(workPackages) {
|
||||
const known = new Set(workPackages.map((wp) => wp.index));
|
||||
@@ -130,9 +157,216 @@ function topologicalOrder(workPackages) {
|
||||
return order;
|
||||
}
|
||||
|
||||
/** 在 YYYY-MM-DD 上加幾個日曆日,回傳同樣格式 */
|
||||
function addDays(date, days) {
|
||||
async function loadHolidays(plan, order, estimates) {
|
||||
const totalDays = order.reduce((total, index) => {
|
||||
const wp = plan.workPackages.find((item) => item.index === index);
|
||||
return total + Math.ceil(estimates.get(index)?.expected ?? wp.days);
|
||||
}, 0);
|
||||
const lastDate = addCalendarDays(plan.startDate, totalDays + 366);
|
||||
const years = [];
|
||||
for (let year = yearOf(plan.startDate); year <= yearOf(lastDate); year += 1) years.push(year);
|
||||
|
||||
const holidays = new Map();
|
||||
for (const year of years) holidays.set(year, await holidaysForYear(year));
|
||||
return holidays;
|
||||
}
|
||||
|
||||
async function holidaysForYear(year) {
|
||||
if (calendarCache.has(year)) return calendarCache.get(year);
|
||||
|
||||
try {
|
||||
const url = process.env[CALENDAR_URL_OVERRIDE]?.trim() || (await calendarResourceUrl(year));
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const holidays = parseCalendar(await response.text(), year);
|
||||
calendarCache.set(year, holidays);
|
||||
return holidays;
|
||||
} catch (error) {
|
||||
const fallback = new Set();
|
||||
calendarCache.set(year, fallback);
|
||||
console.error(`警告:無法取得 ${year} 年台灣辦公日曆(${error.message}),改以週末作為非工作日`);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function calendarResourceUrl(year) {
|
||||
const response = await fetch(CALENDAR_METADATA_URL, { signal: AbortSignal.timeout(10_000) });
|
||||
if (!response.ok) throw new Error(`日曆中繼資料 HTTP ${response.status}`);
|
||||
const body = await response.json();
|
||||
const rocYear = year - 1911;
|
||||
const resources = body.payload?.resources ?? [];
|
||||
const resource = [...resources].reverse().find(
|
||||
(item) =>
|
||||
item.file_format?.toUpperCase() === 'CSV' &&
|
||||
item.description?.startsWith(`${rocYear}年`) &&
|
||||
item.description.includes('政府行政機關辦公日曆表') &&
|
||||
!item.description.includes('Google'),
|
||||
);
|
||||
if (!resource?.url) throw new Error(`資料集沒有 ${year} 年 CSV`);
|
||||
return resource.url;
|
||||
}
|
||||
|
||||
function parseCalendar(text, year) {
|
||||
const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/).filter((line) => line.trim() !== '');
|
||||
if (lines.length < 2) throw new Error('日曆 CSV 沒有資料');
|
||||
const header = parseCsvLine(lines[0]).map((field) => field.trim());
|
||||
const dateColumn = header.indexOf('西元日期');
|
||||
const holidayColumn = header.indexOf('是否放假');
|
||||
if (dateColumn < 0 || holidayColumn < 0) throw new Error('日曆 CSV 缺少必要欄位');
|
||||
|
||||
const holidays = new Set();
|
||||
for (const line of lines.slice(1)) {
|
||||
const fields = parseCsvLine(line);
|
||||
const date = fields[dateColumn]?.trim();
|
||||
const holiday = fields[holidayColumn]?.trim();
|
||||
if (/^\d{8}$/.test(date) && Number(date.slice(0, 4)) === year && holiday !== '0') {
|
||||
holidays.add(`${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}`);
|
||||
}
|
||||
}
|
||||
return holidays;
|
||||
}
|
||||
|
||||
function parseCsvLine(line) {
|
||||
const fields = [];
|
||||
let field = '';
|
||||
let quoted = false;
|
||||
for (let i = 0; i < line.length; i += 1) {
|
||||
const char = line[i];
|
||||
if (char === '"' && line[i + 1] === '"' && quoted) {
|
||||
field += '"';
|
||||
i += 1;
|
||||
} else if (char === '"') {
|
||||
quoted = !quoted;
|
||||
} else if (char === ',' && !quoted) {
|
||||
fields.push(field);
|
||||
field = '';
|
||||
} else {
|
||||
field += char;
|
||||
}
|
||||
}
|
||||
fields.push(field);
|
||||
return fields;
|
||||
}
|
||||
|
||||
function buildSchedule(plan, order, estimates, holidays) {
|
||||
const byIndex = new Map(plan.workPackages.map((wp) => [wp.index, wp]));
|
||||
const early = new Map();
|
||||
for (const index of order) {
|
||||
const wp = byIndex.get(index);
|
||||
const duration = Math.ceil(estimates.get(index)?.expected ?? wp.days);
|
||||
const ES = (wp.depends ?? []).reduce(
|
||||
(latest, dep) => (early.get(dep).EF > latest ? early.get(dep).EF : latest),
|
||||
plan.startDate,
|
||||
);
|
||||
const EF = addWorkdays(ES, duration, holidays);
|
||||
early.set(index, { ES, EF, duration });
|
||||
}
|
||||
|
||||
const projectFinish = [...early.values()].reduce(
|
||||
(latest, item) => (item.EF > latest ? item.EF : latest),
|
||||
plan.startDate,
|
||||
);
|
||||
const successors = new Map(order.map((index) => [index, []]));
|
||||
for (const wp of plan.workPackages) {
|
||||
for (const dep of wp.depends ?? []) successors.get(dep).push(wp.index);
|
||||
}
|
||||
|
||||
const late = new Map();
|
||||
for (const index of [...order].reverse()) {
|
||||
const { duration } = early.get(index);
|
||||
const successorStarts = successors.get(index).map((successor) => late.get(successor).LS);
|
||||
const LF = successorStarts.length === 0 ? projectFinish : successorStarts.reduce(minDate);
|
||||
const LS = subtractWorkdays(LF, duration, holidays);
|
||||
late.set(index, { LS, LF });
|
||||
}
|
||||
|
||||
const items = order.map((index) => {
|
||||
const wp = byIndex.get(index);
|
||||
const estimate = estimates.get(index);
|
||||
const { ES, EF, duration } = early.get(index);
|
||||
const { LS, LF } = late.get(index);
|
||||
const float = workdayDistance(ES, LS, holidays);
|
||||
return {
|
||||
index,
|
||||
title: wp.title,
|
||||
days: wp.days,
|
||||
dueDate: EF,
|
||||
ES,
|
||||
EF,
|
||||
LS,
|
||||
LF,
|
||||
float,
|
||||
critical: float === 0,
|
||||
expectedDays: estimate?.expected ?? wp.days,
|
||||
...(estimate ? { pert: estimate } : {}),
|
||||
};
|
||||
});
|
||||
const criticalPath = items.filter((item) => item.critical).map((item) => item.index);
|
||||
const variance = items.reduce((total, item) => total + (item.pert?.variance ?? 0), 0);
|
||||
const expectedDuration = items.reduce((total, item) => total + (item.pert?.expected ?? item.days), 0);
|
||||
return {
|
||||
items,
|
||||
criticalPath,
|
||||
project: {
|
||||
finishDate: projectFinish,
|
||||
expectedDuration,
|
||||
variance,
|
||||
standardDeviation: Math.sqrt(variance),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function addWorkdays(date, days, holidays) {
|
||||
let current = date;
|
||||
let remaining = days;
|
||||
while (remaining > 0) {
|
||||
current = addCalendarDays(current, 1);
|
||||
if (isWorkday(current, holidays)) remaining -= 1;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function subtractWorkdays(date, days, holidays) {
|
||||
let current = date;
|
||||
let remaining = days;
|
||||
while (remaining > 0) {
|
||||
current = addCalendarDays(current, -1);
|
||||
if (isWorkday(current, holidays)) remaining -= 1;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function workdayDistance(start, end, holidays) {
|
||||
if (start === end) return 0;
|
||||
let current = start;
|
||||
let distance = 0;
|
||||
while (current < end) {
|
||||
current = addCalendarDays(current, 1);
|
||||
if (isWorkday(current, holidays)) distance += 1;
|
||||
}
|
||||
return distance;
|
||||
}
|
||||
|
||||
function isWorkday(date, holidays) {
|
||||
const day = new Date(`${date}T00:00:00Z`).getUTCDay();
|
||||
return day !== 0 && day !== 6 && !holidays.get(yearOf(date))?.has(date);
|
||||
}
|
||||
|
||||
function minDate(a, b) {
|
||||
return a < b ? a : b;
|
||||
}
|
||||
|
||||
function addCalendarDays(date, days) {
|
||||
const moment = new Date(`${date}T00:00:00Z`);
|
||||
moment.setUTCDate(moment.getUTCDate() + days);
|
||||
return moment.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function validDate(date) {
|
||||
const moment = new Date(`${date}T00:00:00Z`);
|
||||
return !Number.isNaN(moment.valueOf()) && moment.toISOString().slice(0, 10) === date;
|
||||
}
|
||||
|
||||
function yearOf(date) {
|
||||
return Number(date.slice(0, 4));
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 起錶。
|
||||
*
|
||||
* 錶與領取鎖是兩件事:鎖用 assignee 加標籤(見 claim.js),錶只管工時。分開的理由是
|
||||
* 時機不同——鎖要在開工之前就上好,錶則要等到**工作樹真的建好之後**才起。工作樹建立
|
||||
* 失敗會中止整個領取,錶要是先起了,使用者就被計了一段什麼都沒做的時間,
|
||||
* 而工時要準正是工時報表的立足點。
|
||||
*
|
||||
* 自己的錶跑在別顆議題上時擋下,不代勞停錶:那一段時間該記在哪顆議題上只有人知道,
|
||||
* 腳本自作主張會把工時記錯地方。錶已經跑在本議題上則什麼都不做——重新起錶會把已經
|
||||
* 累積的時間切成兩段,而中斷後重跑正是這支腳本最常見的處境。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/timer.js --repo owner/name --index 40 [--host <網址>] [--dry-run]
|
||||
*/
|
||||
import {
|
||||
expectOk,
|
||||
fetchIssue,
|
||||
giteaRequest,
|
||||
listStopwatches,
|
||||
main,
|
||||
parseFlags,
|
||||
parseIndex,
|
||||
parseRepo,
|
||||
preflight,
|
||||
resolveLogin,
|
||||
stopwatchElsewhere,
|
||||
stopwatchOnIssue,
|
||||
} from './lib.js';
|
||||
|
||||
main(async () => {
|
||||
const flags = parseFlags(process.argv.slice(2), {
|
||||
required: ['repo', 'index'],
|
||||
optional: ['host'],
|
||||
booleans: ['dry-run'],
|
||||
});
|
||||
const repo = parseRepo(flags.repo);
|
||||
const index = parseIndex(flags.index);
|
||||
const issuePath = `/repos/${repo}/issues/${index}`;
|
||||
const dryRun = flags['dry-run'] === true;
|
||||
|
||||
const login = resolveLogin({ host: flags.host });
|
||||
// 試跑照樣讀現況:手寫一份固定的清單會跟實作走鐘,也說不出「這顆已經在計時了」
|
||||
if (!dryRun) await preflight(login, repo);
|
||||
const issue = await fetchIssue(login, repo, index);
|
||||
|
||||
const watches = await listStopwatches(login);
|
||||
const 已在計時 = stopwatchOnIssue(watches, repo, index) !== null;
|
||||
if (!已在計時 && watches.length > 0) throw stopwatchElsewhere(watches[0], '起錶');
|
||||
|
||||
// 已經在跑就不重起:重新起錶會把已經累積的時間切成兩段
|
||||
const planned = 已在計時 ? [] : [{ method: 'POST', path: `${issuePath}/stopwatch/start`, body: {} }];
|
||||
|
||||
if (dryRun) {
|
||||
return { dryRun: true, repo, index, title: issue.title, requests: planned, 已在計時 };
|
||||
}
|
||||
|
||||
for (const { method, path, body } of planned) {
|
||||
expectOk(await giteaRequest(login, method, path, { body }), `${method} ${path}`);
|
||||
}
|
||||
|
||||
return {
|
||||
repo,
|
||||
index: issue.number,
|
||||
title: issue.title,
|
||||
url: issue.html_url,
|
||||
碼錶中: true,
|
||||
已在計時,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Failure, packageVersion, parseFlags } from './lib.js';
|
||||
import { verificationPrompt, verificationTargets } from './install.js';
|
||||
import { 診斷, verifyInstall } from './install-verify.js';
|
||||
|
||||
/**
|
||||
* 不寫檔的完整安裝/runtime 驗證;install 寫完轉接檔後也走同一條鏈。
|
||||
* @param {string[]} argv
|
||||
* @returns {object|Failure}
|
||||
*/
|
||||
export function runVerify(argv) {
|
||||
const flags = parseFlags(argv, { optional: ['platform'] });
|
||||
const verify = verifyInstall({
|
||||
version: packageVersion(),
|
||||
prompt: verificationPrompt(),
|
||||
platforms: verificationTargets(flags.platform),
|
||||
});
|
||||
return verify.ok ? verify : new Failure('RUNTIME_VERIFY_FAILED', 診斷(verify), verify);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { ScriptError, onPath, packageVersion, promptsDir } from './lib.js';
|
||||
|
||||
/**
|
||||
* 回報這一份 tea-sdlc 的版本、實際 executable 與可用流程;不連網、不改檔。
|
||||
* @param {string[]} argv
|
||||
* @returns {object}
|
||||
*/
|
||||
export function runVersion(argv) {
|
||||
if (argv.length > 0) {
|
||||
throw new ScriptError('UNKNOWN_FLAG', 'sdlc-version 不接受參數');
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'tea-sdlc',
|
||||
version: packageVersion(),
|
||||
executable: onPath('tea-sdlc'),
|
||||
commands: readdirSync(promptsDir())
|
||||
.filter((entry) => entry.endsWith('.md'))
|
||||
.map((entry) => entry.slice(0, -3))
|
||||
.sort(),
|
||||
};
|
||||
}
|
||||
+4
-19
@@ -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,13 +22,12 @@ import {
|
||||
parseRepo,
|
||||
preflight,
|
||||
resolveLogin,
|
||||
stopwatchOnIssue,
|
||||
} from './lib.js';
|
||||
import {
|
||||
checklistInSection,
|
||||
listSection,
|
||||
parseSections,
|
||||
referencedIndex,
|
||||
requirementIndex,
|
||||
tableRows,
|
||||
textSection,
|
||||
} from './issue-body.js';
|
||||
@@ -56,8 +54,7 @@ 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` },
|
||||
{ method: 'GET', path: `${issuePath}/timeline` },
|
||||
],
|
||||
note: UNMERGED_COMMENT_NOTE,
|
||||
};
|
||||
@@ -77,7 +74,7 @@ main(async () => {
|
||||
index: issue.number,
|
||||
url: issue.html_url,
|
||||
title: issue.title,
|
||||
需求議題: referencedIndex(sections, '關聯', '需求議題'),
|
||||
需求議題: requirementIndex(sections),
|
||||
描述: textSection(sections, '描述'),
|
||||
架構圖: textSection(sections, '架構圖'),
|
||||
範圍邊界: listSection(sections, '範圍邊界'),
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 列出一顆需求議題底下的工作包。
|
||||
*
|
||||
* 使用者手上常常只有需求議題的編號——留言發在那裡、討論也在那裡——但真正要動手的
|
||||
* 單位是工作包。這一支把「哪幾顆工作包掛在這顆需求底下」答出來,讓他從清單裡挑一顆,
|
||||
* 而不是自己去 Gitea 網頁上翻。
|
||||
*
|
||||
* **判準沿用工作包抽取那一套**:關聯段落裡的 `需求議題:#<編號>`。判準與 wp-extract 的
|
||||
* `需求議題` 欄位共用 issue-body 的 requirementIndex,不是各寫一份長得像的解析——
|
||||
* 標籤、標題前綴、相依關係都當得了歸屬判準,但各發明一套就會與抽取契約分岔。
|
||||
*
|
||||
* **PR 不算工作包。** 每個 PR 都是議題,而 pr-create 產出的 PR 描述本來就有
|
||||
* 「需求議題:#N」那一行,只看 body 會把 PR 混進清單裡。
|
||||
*
|
||||
* 清單逐頁讀完,讀不完寧可報錯:半份清單會讓使用者從缺了幾顆的清單裡挑,
|
||||
* 而且他看不出來缺的是哪幾顆。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/wp-list.js --repo owner/name --requirement 7 [--host <網址>] [--dry-run]
|
||||
*/
|
||||
import {
|
||||
main,
|
||||
pages,
|
||||
parseFlags,
|
||||
parseIndex,
|
||||
parseRepo,
|
||||
preflight,
|
||||
resolveLogin,
|
||||
} from './lib.js';
|
||||
import { parseSections, requirementIndex } from './issue-body.js';
|
||||
|
||||
main(async () => {
|
||||
const flags = parseFlags(process.argv.slice(2), {
|
||||
required: ['repo', 'requirement'],
|
||||
optional: ['host'],
|
||||
booleans: ['dry-run'],
|
||||
});
|
||||
const repo = parseRepo(flags.repo);
|
||||
const requirement = parseIndex(flags.requirement, '--requirement');
|
||||
const issuesPath = `/repos/${repo}/issues`;
|
||||
|
||||
if (flags['dry-run']) {
|
||||
return {
|
||||
dryRun: true,
|
||||
repo,
|
||||
需求議題: requirement,
|
||||
requests: [{ method: 'GET', path: issuesPath }],
|
||||
note: '議題清單逐頁讀完,頁數取決於 repo 的議題總數,事前無法列舉。',
|
||||
};
|
||||
}
|
||||
|
||||
const login = resolveLogin({ host: flags.host });
|
||||
await preflight(login, repo);
|
||||
|
||||
const 工作包 = [];
|
||||
for await (const issues of pages(login, issuesPath, {
|
||||
query: { state: 'all' },
|
||||
limitCode: 'WORK_PACKAGE_LIMIT',
|
||||
limitHint: `翻不完 ${repo} 的議題,列不出 #${requirement} 底下的完整工作包清單;請直接在 Gitea 上確認`,
|
||||
})) {
|
||||
for (const issue of issues) {
|
||||
if (!belongsTo(issue, requirement)) continue;
|
||||
工作包.push({
|
||||
index: issue.number,
|
||||
title: issue.title,
|
||||
url: issue.html_url,
|
||||
state: issue.state,
|
||||
assignee: issue.assignee?.login ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 依編號排序:Gitea 的回傳順序會隨排序設定而變,清單的順序卻是使用者挑選的依據
|
||||
工作包.sort((a, b) => a.index - b.index);
|
||||
|
||||
return { repo, 需求議題: requirement, 工作包, 數量: 工作包.length };
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* 這顆議題是不是掛在指定需求底下的工作包。
|
||||
* PR 先擋掉——它的描述也有「需求議題:#N」那一行,但它不是工作包。
|
||||
*/
|
||||
function belongsTo(issue, requirement) {
|
||||
if (issue.pull_request != null) return false;
|
||||
return requirementIndex(parseSections(issue.body)) === requirement;
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{標題}}</title>
|
||||
<style>
|
||||
/* 樣式全部集中在這裡,內文只放佔位——改版面不必動內容,換內容不必碰樣式 */
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--fg: #1f2328;
|
||||
--muted: #59636e;
|
||||
--line: #d1d9e0;
|
||||
--accent: #0969da;
|
||||
--surface: #f6f8fa;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #0d1117;
|
||||
--fg: #e6edf3;
|
||||
--muted: #9198a1;
|
||||
--line: #3d444d;
|
||||
--accent: #4493f8;
|
||||
--surface: #151b23;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0d1117;
|
||||
--fg: #e6edf3;
|
||||
--muted: #9198a1;
|
||||
--line: #3d444d;
|
||||
--accent: #4493f8;
|
||||
--surface: #151b23;
|
||||
}
|
||||
|
||||
* { 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: 900px; margin: 0 auto; }
|
||||
|
||||
header { border-bottom: 1px solid var(--line); padding-bottom: 24px; margin-bottom: 40px; }
|
||||
h1 { font-size: 30px; line-height: 1.3; margin: 0 0 12px; }
|
||||
.meta { color: var(--muted); font-size: 14px; }
|
||||
.meta a { color: var(--accent); text-decoration: none; }
|
||||
.meta a:hover { text-decoration: underline; }
|
||||
|
||||
.lede {
|
||||
font-size: 21px;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 40px;
|
||||
padding: 20px 24px;
|
||||
background: var(--surface);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
section { margin-bottom: 40px; }
|
||||
h2 { font-size: 15px; letter-spacing: .08em; text-transform: uppercase;
|
||||
color: var(--muted); margin: 0 0 16px; font-weight: 600; }
|
||||
ul { margin: 0; padding-left: 22px; }
|
||||
li { margin-bottom: 8px; }
|
||||
|
||||
figure { margin: 0; padding: 24px; background: var(--surface);
|
||||
border: 1px solid var(--line); border-radius: 8px; overflow-x: auto; }
|
||||
figure .mermaid { display: flex; justify-content: center; }
|
||||
|
||||
footer { margin-top: 56px; padding-top: 20px; border-top: 1px solid var(--line);
|
||||
color: var(--muted); font-size: 13px; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
body { padding: 32px 16px 64px; }
|
||||
h1 { font-size: 24px; }
|
||||
.lede { font-size: 18px; padding: 16px 18px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<h1>{{標題}}</h1>
|
||||
<p class="meta">{{來源議題}}</p>
|
||||
</header>
|
||||
|
||||
<p class="lede">{{總覽}}</p>
|
||||
|
||||
<section>
|
||||
<h2>目標</h2>
|
||||
<ul>{{目標}}</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>流程</h2>
|
||||
<figure><div class="mermaid">{{流程圖}}</div></figure>
|
||||
</section>
|
||||
|
||||
{{工作包全景}}
|
||||
|
||||
<footer>{{頁尾}}</footer>
|
||||
</main>
|
||||
|
||||
<script type="module">
|
||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
||||
const dark = matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
mermaid.initialize({ startOnLoad: true, theme: dark ? 'dark' : 'default' });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+4
-22
@@ -1,25 +1,7 @@
|
||||
# 工時報表 {{期間}}
|
||||
# 工時報表
|
||||
|
||||
{{範圍}}
|
||||
**不可用**
|
||||
|
||||
## 總計
|
||||
週報、月報、年報目前不可用;時間追蹤功能已移除。
|
||||
|
||||
| 實際工時 | 估算人天 | 已估實際 | 落差 |
|
||||
| --- | --- | --- | --- |
|
||||
| {{實際工時}} | {{估算人天}} | {{已估實際}} | {{落差}} |
|
||||
|
||||
## 分段小計
|
||||
|
||||
| 段 | 起迄 | 實際工時 |
|
||||
| --- | --- | --- |
|
||||
{{分段}}
|
||||
|
||||
## 逐議題
|
||||
|
||||
| 議題 | 標題 | 實際工時 | 估算人天 | 落差 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
{{議題}}
|
||||
|
||||
## 附註
|
||||
|
||||
{{附註}}
|
||||
狀態碼:`REPORT_UNAVAILABLE`
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
| --- | --- |
|
||||
{{名詞表}}
|
||||
|
||||
## 流程圖
|
||||
## 文件
|
||||
|
||||
{{流程圖}}
|
||||
{{文件}}
|
||||
|
||||
## 驗收標準
|
||||
|
||||
|
||||
@@ -9,8 +9,7 @@ import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { repoRoot, manifest, runBin } from './helpers/run-script.js';
|
||||
|
||||
/** 入口必須認得的四個名字,缺一個就有轉接檔或文件會指向不存在的子指令 */
|
||||
const SUBCOMMANDS = ['install', 'uninstall', 'prompt', 'status'];
|
||||
const SUBCOMMANDS = ['install', 'uninstall', 'prompt', 'status', 'sdlc-version', 'verify'];
|
||||
|
||||
|
||||
// ── 子指令解析 ─────────────────────────────────────────────────────
|
||||
@@ -82,7 +81,8 @@ test('打包內容以白名單決定:四個正本目錄都在,測試與暫
|
||||
const packed = JSON.parse(
|
||||
execFileSync('npm', ['pack', '--dry-run', '--json'], { cwd: repoRoot, encoding: 'utf8' }),
|
||||
);
|
||||
const files = packed[0].files.map((file) => file.path);
|
||||
const manifest = Array.isArray(packed) ? packed[0] : packed[Object.keys(packed)[0]];
|
||||
const files = manifest.files.map((file) => file.path);
|
||||
|
||||
for (const dir of ['prompts/', 'scripts/', 'templates/', 'references/', 'bin/']) {
|
||||
assert.ok(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -55,9 +55,9 @@ function routes(overrides = {}, { body = BODY, comments = [101, 102] } = {}) {
|
||||
status: 200,
|
||||
body: { number: INDEX, ...req.body, html_url: `https://x/${INDEX}` },
|
||||
}),
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: {
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: {
|
||||
status: 200,
|
||||
body: comments.map((id) => ({ id, body: `留言 ${id}`, user: { login: 'someone' } })),
|
||||
body: comments.map((id) => ({ id, type: 'comment', body: `留言 ${id}`, user: { login: 'someone' } })),
|
||||
},
|
||||
});
|
||||
for (const id of comments) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readReference, readPrompt, assertNeutralPrompt } from './helpers/prompt-doc.js';
|
||||
|
||||
const RULES = readReference('delegation');
|
||||
const PROMPTS = ['sdlc-plan', 'sdlc-analyze', 'sdlc-feat', 'sdlc-fix', 'sdlc-sync', 'sdlc-report'];
|
||||
const EXPECTED = [
|
||||
['sdlc-plan', '列出九段落依據與缺漏'],
|
||||
['sdlc-analyze', '對四份清單列出疑點'],
|
||||
['sdlc-analyze', '算出截止日'],
|
||||
['sdlc-feat', '把議題標題翻成英文'],
|
||||
['sdlc-feat', '分批提交方案'],
|
||||
];
|
||||
|
||||
function tableEntries() {
|
||||
return [...RULES.matchAll(/^\| `([^`]+)` \| ([^|]+) \|/gm)]
|
||||
.map((match) => [match[1], match[2].trim()]);
|
||||
}
|
||||
|
||||
function promptMarkers(name) {
|
||||
const prompt = readPrompt(name);
|
||||
return [...prompt.matchAll(/^### (?:\d+\.\s+)?(.+?)〔可委派〕\s*$/gm)]
|
||||
.map((match) => [name, match[1].trim()]);
|
||||
}
|
||||
|
||||
// ── 四條判準與雙向集合 ────────────────────────────────────────────
|
||||
|
||||
test('委派規則保留四條判準與兩條硬排除', () => {
|
||||
for (const term of ['可驗證的成品', '不會詢問使用者', '失敗能被呼叫端偵測', '不直接寫入 Gitea 或 git']) {
|
||||
assert.match(RULES, new RegExp(term));
|
||||
}
|
||||
});
|
||||
|
||||
test('委派表與六份正本的標記集合完全一致', () => {
|
||||
const table = tableEntries();
|
||||
const markers = PROMPTS.flatMap(promptMarkers);
|
||||
assert.deepEqual(table, EXPECTED);
|
||||
assert.deepEqual(markers, EXPECTED);
|
||||
});
|
||||
|
||||
test('每個標記步驟都說明能力降級與部分委派邊界', () => {
|
||||
for (const [name, step] of EXPECTED) {
|
||||
const prompt = readPrompt(name);
|
||||
const marker = prompt.match(new RegExp(`^### (?:\\d+\\.\\s+)?${step}〔可委派〕\\s*$`, 'm'));
|
||||
assert.ok(marker, `${name} 缺少標記:${step}`);
|
||||
const start = marker.index;
|
||||
const body = prompt.slice(start, prompt.indexOf('\n### ', start + 1) === -1 ? prompt.length : prompt.indexOf('\n### ', start + 1));
|
||||
assert.match(body, /你的環境若能把工作交給子代理|實際執行.*不委派/);
|
||||
}
|
||||
});
|
||||
|
||||
// ── 六份正本的平台與文字契約 ──────────────────────────────────────
|
||||
|
||||
test('六份流程正本均為平台中立且使用正確 description 前綴', () => {
|
||||
for (const name of PROMPTS) assertNeutralPrompt(readPrompt(name), name);
|
||||
});
|
||||
|
||||
test('沒有委派標記的流程仍明確列入規則表的空集合', () => {
|
||||
for (const name of ['sdlc-fix', 'sdlc-sync', 'sdlc-report']) {
|
||||
assert.equal(promptMarkers(name).length, 0, `${name} 不應偷偷出現委派步驟`);
|
||||
}
|
||||
assert.match(RULES, /sdlc-sync.*sdlc-fix.*sdlc-report/);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readReference, readTemplate } from './helpers/prompt-doc.js';
|
||||
|
||||
const rules = readReference('delivery-types');
|
||||
const template = readTemplate('requirement-issue');
|
||||
const TYPES = [
|
||||
'需求描述概要',
|
||||
'WBS(工作分解結構)',
|
||||
'流程圖',
|
||||
'甘特圖',
|
||||
'PERT 圖',
|
||||
'關鍵路徑圖',
|
||||
'API 契約文件',
|
||||
];
|
||||
|
||||
function sectionOf(text, heading) {
|
||||
const lines = text.split('\n');
|
||||
const start = lines.findIndex((line) => line.replace(/^### (?:\d+\. )?/, '') === heading);
|
||||
assert.notEqual(start, -1, `規則正本缺少「${heading}」`);
|
||||
const rest = lines.slice(start + 1);
|
||||
const end = rest.findIndex((line) => line.startsWith('### '));
|
||||
return (end === -1 ? rest : rest.slice(0, end)).join('\n');
|
||||
}
|
||||
|
||||
test('需求模板以文件取代流程圖,且保留固定段落順序', () => {
|
||||
const headings = [...template.matchAll(/^## (.+)$/gm)].map((match) => match[1]);
|
||||
assert.deepEqual(headings, [
|
||||
'總覽', '背景', '目標', '非目標', '領域名詞表', '文件', '驗收標準', '影響範圍', '未決事項',
|
||||
]);
|
||||
assert.match(template, /\{\{文件\}\}/);
|
||||
assert.equal(template.includes('## 流程圖'), false);
|
||||
});
|
||||
|
||||
test('規則正本逐一交代七種文件的必要內容、產出位置與 ELI5 變體', () => {
|
||||
assert.match(rules, /^## 七種交付類型$/m);
|
||||
for (const type of TYPES) {
|
||||
const section = sectionOf(rules, type);
|
||||
assert.match(section, /必要內容/);
|
||||
assert.match(section, /產出位置/);
|
||||
assert.match(section, /ELI5 變體/);
|
||||
}
|
||||
});
|
||||
|
||||
test('API 契約規則禁止寫入目標專案,且摘要可由來源重產', () => {
|
||||
const section = sectionOf(rules, 'API 契約文件');
|
||||
assert.match(section, /禁止把 API 契約文件寫入目標專案 repo/);
|
||||
assert.match(rules, /摘要可由同一份來源重新產生/);
|
||||
});
|
||||
|
||||
test('交付文件共通規則保留逐一確認與預覽能力分流', () => {
|
||||
assert.match(rules, /逐一確認/);
|
||||
assert.match(rules, /可開啟、可分享的預覽/);
|
||||
assert.match(rules, /沒有預覽能力時依使用者確認的方式交付/);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { repoRoot } from './helpers/run-script.js';
|
||||
|
||||
const files = [
|
||||
...['sdlc-plan', 'sdlc-analyze', 'sdlc-feat', 'sdlc-fix', 'sdlc-sync', 'sdlc-report']
|
||||
.map((name) => join(repoRoot, 'prompts', `${name}.md`)),
|
||||
join(repoRoot, 'references', 'delegation.md'),
|
||||
join(repoRoot, 'AGENTS.md'),
|
||||
join(repoRoot, 'README.md'),
|
||||
join(repoRoot, 'docs', 'adr', '0001-以集中式雜湊路徑的-worktree-隔離平行工作包.md'),
|
||||
join(repoRoot, 'docs', 'adr', '0002-以能力描述而非工具名表達委派.md'),
|
||||
];
|
||||
|
||||
test('流程、規則與文件資產都是合法 UTF-8 且沒有替代字元', () => {
|
||||
for (const path of files) {
|
||||
const bytes = readFileSync(path);
|
||||
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
assert.equal(text.includes('\uFFFD'), false, `${path} 含替代字元`);
|
||||
}
|
||||
});
|
||||
|
||||
test('六份流程正本都以繁體中文 description 開頭', () => {
|
||||
for (const path of files.slice(0, 6)) {
|
||||
const text = readFileSync(path, 'utf8');
|
||||
assert.match(text, /^description: 僅由 \/sdlc-[a-z-]+ 指令叫用。/m, path);
|
||||
}
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
* 過去,回推就自然指向假根,跑的仍是真正的程式碼;順便把「plugin 目錄不完整」
|
||||
* 那條路徑一起測得到——少給哪個目錄由測試自己決定。
|
||||
*/
|
||||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { repoRoot, runBin, tmpRoot } from './run-script.js';
|
||||
|
||||
@@ -20,7 +20,8 @@ export const fakePrompt = (name) =>
|
||||
* prompts 要放進 prompts/ 的正本,鍵為指令名;
|
||||
* omit 故意不建立的目錄,用來造出「plugin 目錄不完整」;
|
||||
* version 覆寫假根的套件版本
|
||||
* @returns {{root: string, run: (args: string[], opts?: object) => Promise<object>}}
|
||||
* @returns {{root: string, shim: string, run: (args: string[], opts?: object) => Promise<object>}}
|
||||
* shim 是放著這份假 plugin 的 tea-sdlc 的目錄,預設已經加進 run 的 PATH
|
||||
*/
|
||||
export function makeFakePlugin(t, { prompts = {}, omit = [], version } = {}) {
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
@@ -44,5 +45,36 @@ export function makeFakePlugin(t, { prompts = {}, omit = [], version } = {}) {
|
||||
writeFileSync(join(root, 'prompts', `${name}.md`), text);
|
||||
}
|
||||
|
||||
return { root, run: (args, opts = {}) => runBin(args, { ...opts, root }) };
|
||||
const shim = makeShim(root);
|
||||
|
||||
return {
|
||||
root,
|
||||
shim,
|
||||
// 預設把這份假 plugin 的 tea-sdlc 放進 PATH:真實使用者是 npm i -g 裝的,
|
||||
// 叫用鏈上本來就有這一環。要測「PATH 上找不到」的那條路徑就傳 shim: false。
|
||||
run: (args, { shim: onPath = true, path, ...opts } = {}) =>
|
||||
runBin(args, {
|
||||
...opts,
|
||||
root,
|
||||
path: onPath ? [shim, path ?? process.env.PATH].join(':') : path,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 替一份假 plugin 根造出可以從 PATH 叫到的 `tea-sdlc`。
|
||||
*
|
||||
* install 的驗證會真的去 PATH 上把 tea-sdlc 找出來執行——那正是它要驗的那一環。
|
||||
* 測試裡若沒有這個殼,驗到的就只是「測試環境沒有裝 tea-sdlc」,而不是待驗的東西。
|
||||
* @param {string} root 假 plugin 根
|
||||
* @returns {string} 殼所在的目錄,加進 PATH 就能叫到
|
||||
*/
|
||||
function makeShim(root) {
|
||||
const dir = join(root, 'shim');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
const path = join(dir, 'tea-sdlc');
|
||||
writeFileSync(path, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(join(root, 'bin', 'tea-sdlc.js'))} "$@"\n`);
|
||||
chmodSync(path, 0o755);
|
||||
return dir;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,53 @@ export function assertNeutralPrompt(prompt, command) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 可委派的標記。寫成標題後綴,不是 emoji 也不是 HTML 註解——那兩種模型讀不穩。 */
|
||||
export const DELEGATABLE = '〔可委派〕';
|
||||
|
||||
/**
|
||||
* 正本裡的每一個編號步驟:名字、有沒有被標成可委派、以及它的內文。
|
||||
*
|
||||
* 步驟的界線是下一個 `##` 或 `###` 標題;段落標題(`## 第二段…`)不算步驟,
|
||||
* 但會把前一步收尾,否則一段的最後一步會把整個段落的收場白都吃進來。
|
||||
* @param {string} prompt 正本內容
|
||||
* @returns {{name: string, marked: boolean, body: string}[]}
|
||||
*/
|
||||
export function promptSteps(prompt) {
|
||||
const lines = prompt.split('\n');
|
||||
// 先收齊所有標題的行號,每一步的結尾就是它後面最近的那一個
|
||||
const 標題行 = [];
|
||||
lines.forEach((line, at) => {
|
||||
if (/^#{2,3} /.test(line)) 標題行.push(at);
|
||||
});
|
||||
|
||||
return 標題行
|
||||
.map((at, i) => ({ at, 到: 標題行[i + 1] ?? lines.length }))
|
||||
.filter(({ at }) => /^### \d+\. /.test(lines[at]))
|
||||
.map(({ at, 到 }) => {
|
||||
const raw = /^### \d+\. (.+)$/.exec(lines[at])[1].trim();
|
||||
const marked = raw.endsWith(DELEGATABLE);
|
||||
return {
|
||||
name: (marked ? raw.slice(0, -DELEGATABLE.length) : raw).trim(),
|
||||
marked,
|
||||
body: lines.slice(at, 到).join('\n'),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出正本裡某一個編號步驟的內容,**以名字取而不是以編號取**。
|
||||
* 步驟會增刪、編號會整批位移,名字不會;用編號寫的測試會在別人插一步時無聲地
|
||||
* 框到另一段內容上,而那種失敗看起來像是正本掉了東西。
|
||||
* @param {string} prompt 正本內容
|
||||
* @param {string} name 步驟名,不含可委派後綴
|
||||
* @returns {string} 該步驟的標題與內文,到下一個 `##` 或 `###` 標題為止
|
||||
*/
|
||||
export function promptStep(prompt, name) {
|
||||
const step = promptSteps(prompt).find((one) => one.name === name);
|
||||
assert.ok(step, `正本裡找不到「${name}」這一步`);
|
||||
return step.body;
|
||||
}
|
||||
|
||||
/**
|
||||
* 斷言模板的 `## 標題` 就是這組段落,順序一致。
|
||||
* 段落順序即下游抽取契約的解析依據,兩邊必須一起改。
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* 安裝完成等於驗過能用:install 寫完轉接檔之後,真的把那條叫用鏈走一遍。
|
||||
*
|
||||
* 為什麼要驗這條鏈,見 scripts/install-verify.js 開頭。這裡只交代測法:一律在臨時家目錄上
|
||||
* 真的寫檔、真的把 tea-sdlc 放上 PATH、真的執行它,再斷言結果。只驗「有沒有呼叫某個函式」
|
||||
* 的話,正好驗不到唯一會壞的那一環。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { manifest, pathWithOnly, tmpRoot } from './helpers/run-script.js';
|
||||
import { fakePrompt, makeFakePlugin } from './helpers/fake-plugin.js';
|
||||
import { startStubGitea } from './helpers/stub-gitea.js';
|
||||
|
||||
const PROMPTS = { 'sdlc-plan': fakePrompt('sdlc-plan'), 'sdlc-feat': fakePrompt('sdlc-feat') };
|
||||
|
||||
/** 一個只「裝了」claude 與 kiro 的臨時家目錄 */
|
||||
function makeHome(t) {
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
const home = mkdtempSync(join(tmpRoot, 'home-'));
|
||||
t.after(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
for (const dir of ['.claude', '.kiro', 'work']) mkdirSync(join(home, dir), { recursive: true });
|
||||
return home;
|
||||
}
|
||||
|
||||
const inHome = (plugin, home) => (args, opts = {}) =>
|
||||
plugin.run(args, { env: { HOME: home }, cwd: join(home, 'work'), ...opts });
|
||||
|
||||
/** 這次安裝實際寫出去的每一份轉接檔 */
|
||||
const adaptersOf = (json) => json.data.platforms.flatMap((platform) => platform.adapters);
|
||||
|
||||
const byName = (verify) => Object.fromEntries(verify.platforms.map((p) => [p.name, p]));
|
||||
|
||||
/**
|
||||
* 把這份假 plugin 的 tea-sdlc 放上本行程的 PATH,測試結束後還原。
|
||||
*
|
||||
* 直接叫 verifyInstall 的測試才需要這個:它跟 install 不一樣,走的是本行程的 PATH。
|
||||
* 叫用鏈那一環要是通的,那些測試的 fail 才只可能來自轉接檔。
|
||||
*/
|
||||
function 把tea_sdlc放上PATH(plugin, t) {
|
||||
const 原本的 = process.env.PATH;
|
||||
process.env.PATH = [plugin.shim, 原本的].join(':');
|
||||
t.after(() => { process.env.PATH = 原本的; });
|
||||
}
|
||||
|
||||
/**
|
||||
* 組出 install 剛寫完轉接檔、正要交給驗證的那個樣子。純粹組資料,不碰環境。
|
||||
* @param {string} home 臨時家目錄
|
||||
* @param {object} plugin 假 plugin,取它的版本
|
||||
*/
|
||||
function 裝好的樣子(home, plugin) {
|
||||
const version = JSON.parse(readFileSync(join(plugin.root, 'package.json'), 'utf8')).version;
|
||||
const names = Object.keys(PROMPTS).sort();
|
||||
|
||||
return {
|
||||
version,
|
||||
prompt: { name: names[0], text: PROMPTS[names[0]] },
|
||||
platforms: [
|
||||
{
|
||||
name: 'claude',
|
||||
adapters: names.map((name) => ({ name, path: join(home, '.claude', 'commands', `${name}.md`) })),
|
||||
},
|
||||
{
|
||||
name: 'kiro',
|
||||
adapters: names.map((name) => ({ name, path: join(home, '.kiro', 'skills', name, 'SKILL.md') })),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ── 全部通過 ───────────────────────────────────────────────────────
|
||||
|
||||
test('轉接檔寫完就驗一次真實的叫用鏈,逐平台回報 pass', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t);
|
||||
|
||||
const { code, json } = await inHome(plugin, home)(['install']);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.ok, true);
|
||||
assert.equal(json.data.verify.ok, true);
|
||||
const claude = byName(json.data.verify).claude;
|
||||
assert.equal(claude.name, 'claude');
|
||||
assert.equal(claude.ok, true);
|
||||
assert.equal(claude.status, 'pass');
|
||||
assert.deepEqual(claude.runtime.commands, ['sdlc-feat', 'sdlc-plan']);
|
||||
const kiro = byName(json.data.verify).kiro;
|
||||
assert.equal(kiro.name, 'kiro');
|
||||
assert.equal(kiro.ok, true);
|
||||
assert.equal(kiro.status, 'not-supported');
|
||||
assert.deepEqual(kiro.failures, []);
|
||||
});
|
||||
|
||||
test('驗證是真的把 PATH 上的 tea-sdlc 找出來執行,不是查有沒有這個檔', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t);
|
||||
|
||||
const { json } = await inHome(plugin, home)(['install']);
|
||||
const { chain } = json.data.verify;
|
||||
|
||||
assert.equal(chain.ok, true);
|
||||
assert.equal(chain.command, 'tea-sdlc');
|
||||
assert.equal(chain.resolved, join(plugin.shim, 'tea-sdlc'));
|
||||
assert.ok(json.data.commands.includes(chain.prompt), `取回的是 ${chain.prompt}`);
|
||||
});
|
||||
|
||||
|
||||
// ── 中間那一環斷掉 ─────────────────────────────────────────────────
|
||||
|
||||
test('PATH 上找不到 tea-sdlc 時整體 ok:false,並指出病灶在 PATH 而不是轉接檔', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t);
|
||||
|
||||
const { code, json } = await inHome(plugin, home)(['install'], { shim: false, path: pathWithOnly(['node']) });
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.ok, false);
|
||||
assert.equal(json.data.verify.chain.ok, false);
|
||||
assert.equal(json.data.verify.chain.resolved, null);
|
||||
assert.match(json.data.verify.chain.病灶, /PATH/);
|
||||
assert.match(json.data.verify.chain.修復, /npm/);
|
||||
// 轉接檔本身沒有問題,刪掉它一點幫助也沒有——這裡要分得開
|
||||
assert.equal(byName(json.data.verify).claude.ok, true);
|
||||
});
|
||||
|
||||
test('PATH 上的 tea-sdlc 是另一份安裝時驗得出來——取回的正本跟這一份不一樣', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const 另一份 = makeFakePlugin(t, {
|
||||
prompts: Object.fromEntries(
|
||||
Object.entries(PROMPTS).map(([name, text]) => [name, `${text}\n舊版多出來的一段。\n`]),
|
||||
),
|
||||
});
|
||||
const home = makeHome(t);
|
||||
|
||||
const { code, json } = await inHome(plugin, home)(['install'], {
|
||||
shim: false,
|
||||
path: [另一份.shim, process.env.PATH].join(':'),
|
||||
});
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.data.verify.chain.ok, false);
|
||||
assert.equal(json.data.verify.chain.resolved, join(另一份.shim, 'tea-sdlc'));
|
||||
assert.match(json.data.verify.chain.病灶, /正本/);
|
||||
});
|
||||
|
||||
|
||||
test('PATH 上的 tea-sdlc 叫得到卻跑不完時,把它的 stderr 當成病灶講出來,自己的 stderr 仍然乾淨', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t);
|
||||
// 裝壞了的 tea-sdlc:叫得到、跑不完。輸出契約是「stderr 永遠乾淨,呼叫端只讀 stdout」,
|
||||
// 所以子行程罵的話要被收進病灶裡,不能直接漏到我們的 stderr 上。
|
||||
const 壞殼 = join(home, 'bin');
|
||||
mkdirSync(壞殼, { recursive: true });
|
||||
writeFileSync(join(壞殼, 'tea-sdlc'), '#!/bin/sh\necho "Cannot find module node_modules/x" >&2\nexit 1\n');
|
||||
chmodSync(join(壞殼, 'tea-sdlc'), 0o755);
|
||||
|
||||
const { code, stderr, json } = await inHome(plugin, home)(['install'], {
|
||||
shim: false,
|
||||
path: [壞殼, process.env.PATH].join(':'),
|
||||
});
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(stderr, '', '子行程的 stderr 漏出來了');
|
||||
assert.equal(json.data.verify.chain.ok, false);
|
||||
assert.match(json.data.verify.chain.病灶, /Cannot find module/);
|
||||
});
|
||||
|
||||
|
||||
test('PATH 上的 tea-sdlc 自己裝壞了時,病灶講的是它自己報的錯,不是空泛的 Command failed', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
// tea-sdlc 的失敗一律是 stdout 上的一行 JSON(stderr 永遠乾淨),病灶要從那裡撈
|
||||
const 裝壞的 = makeFakePlugin(t, { prompts: PROMPTS, omit: ['templates'] });
|
||||
const home = makeHome(t);
|
||||
|
||||
const { code, json } = await inHome(plugin, home)(['install'], {
|
||||
shim: false,
|
||||
path: [裝壞的.shim, process.env.PATH].join(':'),
|
||||
});
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.data.verify.chain.ok, false);
|
||||
assert.match(json.data.verify.chain.病灶, /PLUGIN_LAYOUT_BROKEN/);
|
||||
assert.equal(/Command failed/.test(json.data.verify.chain.病灶), false, '子行程自己說的話被丟掉了');
|
||||
// 使用者一定會看到的地方也要講得出來
|
||||
assert.match(json.error.message, /PLUGIN_LAYOUT_BROKEN/);
|
||||
});
|
||||
|
||||
|
||||
// ── 轉接檔那一環壞掉 ───────────────────────────────────────────────
|
||||
//
|
||||
// 這兩支直接叫 verifyInstall,因為 install 會先把轉接檔寫過一遍才驗——從 CLI 進去
|
||||
// 沒有辦法讓它看到一份壞掉的轉接檔。驗的仍然是真的檔案與真的家目錄,只是少了寫入那一步。
|
||||
|
||||
test('轉接檔的叫用行不對時該平台 fail,其他平台照常 pass,整體 ok:false', async (t) => {
|
||||
const { verifyInstall } = await import('../scripts/install-verify.js');
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t);
|
||||
await inHome(plugin, home)(['install']);
|
||||
const 壞掉的 = join(home, '.claude', 'commands', 'sdlc-plan.md');
|
||||
writeFileSync(壞掉的, '執行 `tea-sdlc prompt --name sdlc-plna`,並完全遵照它印出的內容執行。\n');
|
||||
|
||||
把tea_sdlc放上PATH(plugin, t);
|
||||
const verify = verifyInstall(裝好的樣子(home, plugin));
|
||||
|
||||
assert.equal(verify.ok, false);
|
||||
assert.equal(byName(verify).claude.ok, false);
|
||||
assert.equal(byName(verify).kiro.ok, true);
|
||||
assert.deepEqual(byName(verify).claude.failures.map((f) => f.path), [壞掉的]);
|
||||
assert.match(byName(verify).claude.failures[0].病灶, /叫用行/);
|
||||
assert.match(byName(verify).claude.failures[0].修復, /install/);
|
||||
});
|
||||
|
||||
test('轉接檔不見了時該平台 fail,病灶講的是檔案不在', async (t) => {
|
||||
const { verifyInstall } = await import('../scripts/install-verify.js');
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t);
|
||||
await inHome(plugin, home)(['install']);
|
||||
const 不見的 = join(home, '.kiro', 'skills', 'sdlc-feat', 'SKILL.md');
|
||||
rmSync(不見的);
|
||||
|
||||
把tea_sdlc放上PATH(plugin, t);
|
||||
const verify = verifyInstall(裝好的樣子(home, plugin));
|
||||
|
||||
assert.equal(verify.ok, false);
|
||||
assert.equal(byName(verify).kiro.ok, false);
|
||||
assert.deepEqual(byName(verify).kiro.failures.map((f) => f.path), [不見的]);
|
||||
assert.match(byName(verify).kiro.failures[0].病灶, /找不到/);
|
||||
});
|
||||
|
||||
|
||||
test('某個平台的轉接檔驗不過時,install 整體回 ok:false,data 照樣交出去', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t);
|
||||
// 把其中一份轉接檔接到 /dev/null:install 照常寫得進去(不會中途炸掉),
|
||||
// 但讀回來是空的——「寫出去了」與「檔案真的長那樣」不是同一件事,正是這道驗證的理由。
|
||||
const 寫不進去的 = join(home, '.claude', 'commands', 'sdlc-plan.md');
|
||||
mkdirSync(dirname(寫不進去的), { recursive: true });
|
||||
symlinkSync('/dev/null', 寫不進去的);
|
||||
|
||||
const { code, json } = await inHome(plugin, home)(['install']);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.ok, false);
|
||||
assert.equal(json.error.code, 'INSTALL_VERIFY_FAILED');
|
||||
assert.equal(json.data.verify.chain.ok, true, '叫用鏈是通的,壞的只有這一份轉接檔');
|
||||
assert.equal(byName(json.data.verify).claude.ok, false);
|
||||
assert.equal(byName(json.data.verify).kiro.ok, true);
|
||||
// 病灶與修復方式要出現在使用者一定會看到的地方
|
||||
assert.match(json.error.message, /叫用行/);
|
||||
assert.match(json.error.message, /重跑 tea-sdlc install/);
|
||||
});
|
||||
|
||||
|
||||
// ── 失敗不回滾 ─────────────────────────────────────────────────────
|
||||
|
||||
test('驗證失敗時已經寫好的轉接檔一份都不刪', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t);
|
||||
|
||||
// 病灶在 PATH,不在轉接檔:刪掉轉接檔只會讓使用者從「有點舊但能用」變成什麼都沒有
|
||||
const { json } = await inHome(plugin, home)(['install'], { shim: false, path: pathWithOnly(['node']) });
|
||||
|
||||
assert.equal(json.ok, false);
|
||||
for (const path of adaptersOf(json)) {
|
||||
assert.ok(existsSync(path), `${path} 被回滾掉了`);
|
||||
}
|
||||
});
|
||||
|
||||
test('升級情境:驗證失敗也不會把使用者原本能用的舊轉接檔弄不見', async (t) => {
|
||||
const 舊版 = makeFakePlugin(t, { prompts: PROMPTS, version: '0.0.1' });
|
||||
const home = makeHome(t);
|
||||
await inHome(舊版, home)(['install']);
|
||||
|
||||
const 新版 = makeFakePlugin(t, { prompts: PROMPTS, version: '9.9.9' });
|
||||
const { json } = await inHome(新版, home)(['install'], { shim: false });
|
||||
|
||||
assert.equal(json.ok, false);
|
||||
const 轉接檔 = join(home, '.claude', 'commands', 'sdlc-plan.md');
|
||||
assert.ok(existsSync(轉接檔));
|
||||
assert.match(readFileSync(轉接檔, 'utf8'), /--adapter-version 9\.9\.9/);
|
||||
});
|
||||
|
||||
|
||||
// ── 不需要網路、不需要登入 ─────────────────────────────────────────
|
||||
|
||||
test('整個驗證過程一個網路請求都不發', async (t) => {
|
||||
// 取正本是讀套件內的檔案,比對轉接檔是讀本機目錄,兩件事都不該碰到 Gitea
|
||||
const stub = await startStubGitea({});
|
||||
t.after(() => stub.close());
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t);
|
||||
|
||||
const { code } = await inHome(plugin, home)(['install'], {
|
||||
env: { HOME: home, TEA_SDLC_API_BASE: stub.base, TEA_SDLC_TOKEN: 'stub-token' },
|
||||
});
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.deepEqual(stub.requests, []);
|
||||
});
|
||||
|
||||
|
||||
// ── 試跑 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('--dry-run 一個字都不寫,也不因為沒東西可驗就報失敗', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t);
|
||||
|
||||
const { code, json } = await inHome(plugin, home)(['install', '--dry-run']);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.ok, true);
|
||||
assert.equal(json.data.verify.skipped, true);
|
||||
assert.match(json.data.verify.reason, /dry-run/);
|
||||
assert.equal(existsSync(join(home, '.claude', 'commands')), false);
|
||||
});
|
||||
|
||||
|
||||
// ── 打包範圍 ───────────────────────────────────────────────────────
|
||||
|
||||
test('test/ 不在套件白名單裡:驗的是安裝結果,不是開發期的契約', () => {
|
||||
assert.equal(manifest().files.some((entry) => entry.replace(/\/$/, '') === 'test'), false);
|
||||
});
|
||||
+23
-2
@@ -170,12 +170,33 @@ test('支援 command 的四個平台產生 command 轉接檔,另外三個產
|
||||
assert.deepEqual(filesUnder(join(home, '.claude')), ['commands/sdlc-plan.md']);
|
||||
assert.deepEqual(filesUnder(join(home, '.codex')), ['prompts/sdlc-plan.md']);
|
||||
assert.deepEqual(filesUnder(join(home, '.config', 'opencode')), ['command/sdlc-plan.md']);
|
||||
assert.deepEqual(filesUnder(join(home, '.omp')), ['commands/sdlc-plan.md']);
|
||||
assert.deepEqual(filesUnder(join(home, '.omp')), ['agent/commands/sdlc-plan.md']);
|
||||
assert.deepEqual(filesUnder(join(home, '.gemini')), ['skills/sdlc-plan/SKILL.md']);
|
||||
assert.deepEqual(filesUnder(join(home, '.kiro')), ['skills/sdlc-plan/SKILL.md']);
|
||||
assert.deepEqual(filesUnder(join(home, 'work', '.github')), ['skills/sdlc-plan/SKILL.md']);
|
||||
});
|
||||
|
||||
test('七個平台的轉接檔都保留繁體中文且沒有 UTF-8 亂碼', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': fakePrompt('sdlc-plan') } });
|
||||
const home = makeHome(t, ['claude', 'codex', 'opencode', 'oh-my-pi', 'antigravity', 'kiro', 'copilot']);
|
||||
|
||||
await inHome(plugin, home)(['install']);
|
||||
const files = [
|
||||
join(home, '.claude', 'commands', 'sdlc-plan.md'),
|
||||
join(home, '.codex', 'prompts', 'sdlc-plan.md'),
|
||||
join(home, '.config', 'opencode', 'command', 'sdlc-plan.md'),
|
||||
join(home, '.omp', 'agent', 'commands', 'sdlc-plan.md'),
|
||||
join(home, '.gemini', 'skills', 'sdlc-plan', 'SKILL.md'),
|
||||
join(home, '.kiro', 'skills', 'sdlc-plan', 'SKILL.md'),
|
||||
join(home, 'work', '.github', 'skills', 'sdlc-plan', 'SKILL.md'),
|
||||
];
|
||||
|
||||
for (const path of files) {
|
||||
const text = new TextDecoder('utf-8', { fatal: true }).decode(readFileSync(path));
|
||||
assert.match(text, /僅由 \/sdlc-plan 指令叫用。/);
|
||||
}
|
||||
});
|
||||
|
||||
test('轉接檔內容是一句指向 tea-sdlc 的話,不含任何檔案路徑', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS, version: '1.2.3' });
|
||||
const home = makeHome(t, ['claude']);
|
||||
@@ -213,7 +234,7 @@ test('支援關閉自動觸發的 command 平台設上旗標;三個 skill 平
|
||||
join(home, '.claude', 'commands', 'sdlc-plan.md'),
|
||||
join(home, '.codex', 'prompts', 'sdlc-plan.md'),
|
||||
join(home, '.config', 'opencode', 'command', 'sdlc-plan.md'),
|
||||
join(home, '.omp', 'commands', 'sdlc-plan.md'),
|
||||
join(home, '.omp', 'agent', 'commands', 'sdlc-plan.md'),
|
||||
];
|
||||
const skills = [
|
||||
join(home, '.gemini', 'skills', 'sdlc-plan', 'SKILL.md'),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
+39
-22
@@ -40,7 +40,7 @@ const FULL_BODY = `## 總覽
|
||||
| 需求議題 | 描述一項需求的 Gitea issue |
|
||||
| 工作包 | 從需求拆出的可獨立完成的單位 |
|
||||
|
||||
## 流程圖
|
||||
## 文件
|
||||
|
||||
\`\`\`mermaid
|
||||
flowchart TD
|
||||
@@ -75,9 +75,9 @@ function routes(overrides = {}, { body = FULL_BODY, comments = [] } = {}) {
|
||||
labels: [{ id: 55, name: 'ready-for-agent' }],
|
||||
},
|
||||
},
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: {
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: {
|
||||
status: 200,
|
||||
body: comments.map((c, i) => ({ id: 100 + i, body: c.body })),
|
||||
body: comments.map((c, i) => ({ id: 100 + i, type: 'comment', body: c.body })),
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
@@ -108,7 +108,7 @@ test('抽出契約上的每一個欄位', async (t) => {
|
||||
assert.equal(code, 0);
|
||||
assert.deepEqual(Object.keys(json.data).sort(), [
|
||||
'index', 'labels', 'title', 'url',
|
||||
'影響範圍', '未決事項', '未處理留言數', '流程圖',
|
||||
'影響範圍', '未決事項', '未處理留言數', '文件',
|
||||
'目標', '總覽', '背景', '名詞表', '非目標', '驗收標準',
|
||||
].sort());
|
||||
});
|
||||
@@ -159,14 +159,14 @@ test('名詞表解析成 term 與 def,表頭與分隔列不算一筆', async (
|
||||
]);
|
||||
});
|
||||
|
||||
test('流程圖原樣帶出,連圍欄一起', async (t) => {
|
||||
test('文件原樣帶出,連圍欄一起', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
assert.match(json.data.流程圖, /^```mermaid/);
|
||||
assert.match(json.data.流程圖, /flowchart TD/);
|
||||
assert.match(json.data.流程圖, /```$/);
|
||||
assert.match(json.data.文件, /^```mermaid/);
|
||||
assert.match(json.data.文件, /flowchart TD/);
|
||||
assert.match(json.data.文件, /```$/);
|
||||
});
|
||||
|
||||
// ── 模板變體 ───────────────────────────────────────────────────────
|
||||
@@ -192,7 +192,7 @@ test('缺段落回傳空值而不是報錯', async (t) => {
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.總覽, '只有總覽的議題。');
|
||||
assert.equal(json.data.背景, '');
|
||||
assert.equal(json.data.流程圖, '');
|
||||
assert.equal(json.data.文件, '');
|
||||
assert.deepEqual(json.data.目標, []);
|
||||
assert.deepEqual(json.data.名詞表, []);
|
||||
assert.deepEqual(json.data.驗收標準, []);
|
||||
@@ -340,7 +340,7 @@ test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => {
|
||||
json.data.requests.map((r) => `${r.method} ${r.path}`),
|
||||
[
|
||||
`GET /repos/${REPO}/issues/${INDEX}`,
|
||||
`GET /repos/${REPO}/issues/${INDEX}/comments`,
|
||||
`GET /repos/${REPO}/issues/${INDEX}/timeline`,
|
||||
],
|
||||
);
|
||||
assert.match(
|
||||
@@ -354,13 +354,13 @@ test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => {
|
||||
// ── 圍欄與表格的邊界(皆為 code review 抓出的實際缺陷,這裡封住回頭路)────
|
||||
|
||||
test('~~~ 圍欄裡的井字號不是標題', async (t) => {
|
||||
const body = '## 流程圖\n\n~~~\n## 這不是標題\n~~~\n\n## 目標\n\n- 真的目標\n';
|
||||
const body = '## 文件\n\n~~~\n## 這不是標題\n~~~\n\n## 目標\n\n- 真的目標\n';
|
||||
const stub = await withStub(t, {}, { body });
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
assert.deepEqual(json.data.目標, ['真的目標']);
|
||||
assert.match(json.data.流程圖, /這不是標題/, '圍欄內容原樣留在流程圖段落裡');
|
||||
assert.match(json.data.文件, /這不是標題/, '圍欄內容原樣留在文件段落裡');
|
||||
});
|
||||
|
||||
test('圍欄裡的減號不是清單項', async (t) => {
|
||||
@@ -382,14 +382,14 @@ test('圍欄要同種標記才算關閉,混用時不會提早收尾', async (t
|
||||
});
|
||||
|
||||
test('圍欄沒關就到結尾時,其後內容算在圍欄內(與 markdown 渲染一致)', async (t) => {
|
||||
const body = '## 流程圖\n\n```mermaid\nflowchart TD\n\n## 驗收標準\n\n- 被圍欄吃掉\n';
|
||||
const body = '## 文件\n\n```mermaid\nflowchart TD\n\n## 驗收標準\n\n- 被圍欄吃掉\n';
|
||||
const stub = await withStub(t, {}, { body });
|
||||
|
||||
const { code, json } = await run([], stub);
|
||||
|
||||
assert.equal(code, 0, '不該炸掉,只是內容歸屬不同');
|
||||
assert.deepEqual(json.data.驗收標準, []);
|
||||
assert.match(json.data.流程圖, /驗收標準/);
|
||||
assert.match(json.data.文件, /驗收標準/);
|
||||
});
|
||||
|
||||
test('名詞表的欄位內容可以有逸脫的直線', async (t) => {
|
||||
@@ -422,17 +422,20 @@ test('巢狀清單一律攤平,不無聲吃掉內容', async (t) => {
|
||||
assert.deepEqual(json.data.驗收標準, ['上層項目', '巢狀項目']);
|
||||
});
|
||||
|
||||
test('留言逐頁讀完,不是只讀第一頁', async (t) => {
|
||||
// 第一頁滿 50 筆就得再翻一頁;第二頁不滿才收手
|
||||
const page1 = Array.from({ length: 50 }, (_, i) => ({ id: 200 + i }));
|
||||
const page2 = Array.from({ length: 20 }, (_, i) => ({ id: 300 + i }));
|
||||
test('留言 timeline 逐頁讀完並去除重複事件', async (t) => {
|
||||
// 第一頁滿 50 筆就得再翻一頁;第二頁包含重複事件,仍只算一次
|
||||
const page1 = Array.from({ length: 50 }, (_, i) => ({ id: 200 + i, type: 'comment' }));
|
||||
const page2 = [
|
||||
page1.at(-1),
|
||||
...Array.from({ length: 20 }, (_, i) => ({ id: 300 + i, type: 'comment' })),
|
||||
];
|
||||
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) => ({
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: (req) => ({
|
||||
status: 200,
|
||||
body: req.query.page === '1' ? page1 : page2,
|
||||
}),
|
||||
@@ -441,7 +444,21 @@ test('留言逐頁讀完,不是只讀第一頁', async (t) => {
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
const commentPages = stub.requests.filter((r) => r.path.endsWith(`/issues/${INDEX}/comments`));
|
||||
assert.deepEqual(commentPages.map((r) => r.query.page), ['1', '2']);
|
||||
assert.equal(json.data.未處理留言數, 70, '第二頁的留言也要算進來');
|
||||
const timelinePages = stub.requests.filter((r) => r.path.endsWith(`/issues/${INDEX}/timeline`));
|
||||
assert.deepEqual(timelinePages.map((r) => r.query.page), ['1', '2']);
|
||||
assert.equal(json.data.未處理留言數, 70, '重複事件不應重複計入');
|
||||
});
|
||||
|
||||
test('timeline 讀取失敗時回傳可區分的錯誤', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: {
|
||||
status: 502,
|
||||
body: { message: 'upstream unavailable' },
|
||||
},
|
||||
});
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
assert.equal(json.error.code, 'HTTP_ERROR');
|
||||
assert.match(json.error.message, /502/);
|
||||
});
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* 圖解版總覽網頁:模板本身,以及兩份正本裡產生它、把網址寫回議題的規則。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readPrompt, readTemplateFile } from './helpers/prompt-doc.js';
|
||||
|
||||
const template = readTemplateFile('overview-artifact.html');
|
||||
const planPrompt = readPrompt('sdlc-plan');
|
||||
const analyzePrompt = readPrompt('sdlc-analyze');
|
||||
/** 只看產生總覽那一步,避免拿整份正本的任何一處來充數 */
|
||||
const planStep = planPrompt.slice(planPrompt.indexOf('### 6.'), planPrompt.indexOf('### 7.'));
|
||||
|
||||
/** 模板要填的欄位 */
|
||||
const PLACEHOLDERS = ['標題', '來源議題', '總覽', '目標', '流程圖', '工作包全景', '頁尾'];
|
||||
|
||||
// ── 模板 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('模板以 {{變數}} 佔位,欄位齊全', () => {
|
||||
const found = new Set([...template.matchAll(/\{\{([^}]+)\}\}/g)].map((m) => m[1]));
|
||||
for (const name of PLACEHOLDERS) {
|
||||
assert.ok(found.has(name), `模板缺少佔位 {{${name}}}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('樣式與內容分離:樣式集中在 style 區塊,內文不帶 style 屬性', () => {
|
||||
const styleBlocks = template.match(/<style>[\s\S]*?<\/style>/g) ?? [];
|
||||
assert.equal(styleBlocks.length, 1, '樣式應集中在單一 style 區塊');
|
||||
|
||||
const body = template.slice(template.indexOf('<body>'));
|
||||
assert.equal(/\sstyle="/.test(body), false, '內文不該出現行內樣式');
|
||||
});
|
||||
|
||||
test('是一份可以直接開的完整 HTML', () => {
|
||||
assert.match(template, /^<!DOCTYPE html>/);
|
||||
assert.match(template, /<html lang="zh-Hant">/);
|
||||
assert.match(template, /<meta charset="utf-8">/);
|
||||
assert.match(template, /<meta name="viewport"/, '要能在投影與手機上都看得清楚');
|
||||
});
|
||||
|
||||
test('深色模式不靠手動切換也能用', () => {
|
||||
assert.match(template, /prefers-color-scheme: dark/);
|
||||
});
|
||||
|
||||
test('mermaid 圖有被實際渲染,不是把原始碼丟給讀者看', () => {
|
||||
// 只比對「有出現 mermaid 字樣」會被 CDN 網址矇混過去,要驗到真的有初始化
|
||||
const script = template.match(/<script[\s\S]*?<\/script>/)?.[0] ?? '';
|
||||
assert.match(script, /mermaid\.initialize\(/);
|
||||
assert.match(template, /class="mermaid"/);
|
||||
});
|
||||
|
||||
test('模板裡唯一的腳本就是畫圖那一段,沒有夾帶其他邏輯', () => {
|
||||
// AGENTS.md 說模板不含邏輯,這份是唯一的例外,例外要維持在最小範圍
|
||||
const scripts = template.match(/<script[\s\S]*?<\/script>/g) ?? [];
|
||||
assert.equal(scripts.length, 1);
|
||||
assert.equal(/\bfetch\(|localStorage|document\.cookie|XMLHttpRequest/.test(scripts[0]), false);
|
||||
});
|
||||
|
||||
test('工作包全景是整段佔位,規劃階段才填得了空字串', () => {
|
||||
// 規劃階段沒有工作包,整段要能消失,所以佔位不可以被包在寫死的 section 裡
|
||||
const line = template.split('\n').find((l) => l.includes('{{工作包全景}}'));
|
||||
assert.equal(line.trim(), '{{工作包全景}}');
|
||||
});
|
||||
|
||||
// ── sdlc-plan 的步驟 ───────────────────────────────────────────────
|
||||
|
||||
test('規劃正本交代了產生總覽與寫回網址', () => {
|
||||
assert.match(planStep, /templates\/overview-artifact\.html/);
|
||||
assert.match(planStep, /--overview-url/);
|
||||
assert.match(planStep, /issue-update/);
|
||||
});
|
||||
|
||||
test('規劃正本說明流程圖填進模板時不帶圍欄', () => {
|
||||
assert.match(planStep, /不\*\*含\*\*圍欄|\*\*不含\*\*圍欄/);
|
||||
});
|
||||
|
||||
test('規劃階段沒有工作包,正本要說清楚全景填空字串', () => {
|
||||
assert.match(planStep, /\{\{工作包全景\}\}/);
|
||||
assert.match(planStep, /填空字串/);
|
||||
});
|
||||
|
||||
test('正本說明這份網頁是給非技術的人看的', () => {
|
||||
assert.match(planStep, /非技術/);
|
||||
});
|
||||
|
||||
// ── sdlc-analyze 的步驟 ────────────────────────────────────────────
|
||||
|
||||
test('分析正本的全景圖用 graph TD,並畫出相依與時程', () => {
|
||||
const step = analyzePrompt.slice(analyzePrompt.indexOf('### 11.'));
|
||||
assert.match(step, /graph TD/);
|
||||
assert.match(step, /相依/);
|
||||
assert.match(step, /截止日/);
|
||||
});
|
||||
|
||||
test('分析正本沿用同一份模板,不另立一份', () => {
|
||||
assert.match(analyzePrompt, /同一份 `templates\/overview-artifact\.html`/);
|
||||
});
|
||||
|
||||
test('全景圖一樣有節點上限,超過時的做法有交代', () => {
|
||||
const step = analyzePrompt.slice(analyzePrompt.indexOf('### 11.'));
|
||||
assert.match(step, /12/);
|
||||
assert.match(step, /最長路徑/);
|
||||
});
|
||||
|
||||
test('分析正本說明重跑會就地更新,不會留下兩個連結', () => {
|
||||
const step = analyzePrompt.slice(analyzePrompt.indexOf('### 11.'));
|
||||
assert.match(step, /就地更新/);
|
||||
assert.match(step, /只掛一個總覽網址/);
|
||||
});
|
||||
|
||||
// ── 兩份共通 ───────────────────────────────────────────────────────
|
||||
|
||||
test('兩份正本都指名同一支腳本寫回網址', () => {
|
||||
for (const [name, prompt] of [['sdlc-plan', planPrompt], ['sdlc-analyze', analyzePrompt]]) {
|
||||
assert.match(prompt, /--overview-url/, `${name} 沒有指名寫回網址的方式`);
|
||||
}
|
||||
});
|
||||
|
||||
test('規劃正本說明連結會自動附上私有提醒', () => {
|
||||
assert.match(planStep, /私有/);
|
||||
assert.match(planStep, /組織外/);
|
||||
});
|
||||
|
||||
test('規劃正本強調議題原本的白話總覽不被取代', () => {
|
||||
assert.match(planStep, /網頁是補充,不是取代/);
|
||||
});
|
||||
+25
-21
@@ -33,13 +33,23 @@ function routes(overrides = {}, options = {}) {
|
||||
status: 200,
|
||||
body: { ...pull, ...(isPull ? { pull_request: { merged: false } } : {}) },
|
||||
},
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: {
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: {
|
||||
status: 200,
|
||||
body: general.map((c, i) => ({
|
||||
id: 100 + i,
|
||||
body: c.body,
|
||||
user: { login: c.user ?? 'reviewer' },
|
||||
})),
|
||||
body: [
|
||||
...general.map((c, i) => ({
|
||||
id: 100 + i,
|
||||
type: 'comment',
|
||||
body: c.body,
|
||||
user: { login: c.user ?? 'reviewer' },
|
||||
})),
|
||||
...reviews.map((r, i) => ({
|
||||
id: 400 + i,
|
||||
type: 'review',
|
||||
review_id: 200 + i,
|
||||
body: r.body ?? '',
|
||||
user: { login: r.user ?? 'reviewer' },
|
||||
})),
|
||||
],
|
||||
},
|
||||
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: {
|
||||
status: 200,
|
||||
@@ -50,17 +60,6 @@ function routes(overrides = {}, options = {}) {
|
||||
user: { login: r.user ?? 'reviewer' },
|
||||
})),
|
||||
},
|
||||
// 總評在 issue comment 表裡也有一份,timeline 給得出它的 id 與 review_id
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: {
|
||||
status: 200,
|
||||
body: reviews.map((r, i) => ({
|
||||
id: 400 + i,
|
||||
type: 'review',
|
||||
review_id: 200 + i,
|
||||
body: r.body ?? '',
|
||||
user: { login: r.user ?? 'reviewer' },
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
general.forEach((c, i) => {
|
||||
@@ -416,7 +415,7 @@ test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => {
|
||||
json.data.requests.map((r) => `${r.method} ${r.path}`),
|
||||
[
|
||||
`GET /repos/${REPO}/issues/${INDEX}`,
|
||||
`GET /repos/${REPO}/issues/${INDEX}/comments`,
|
||||
`GET /repos/${REPO}/issues/${INDEX}/timeline`,
|
||||
],
|
||||
);
|
||||
assert.match(json.data.note, /reaction|review/);
|
||||
@@ -426,15 +425,20 @@ test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => {
|
||||
// ── 分頁 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('留言逐頁讀完,不是只讀第一頁', async (t) => {
|
||||
const page1 = Array.from({ length: 50 }, (_, i) => ({ id: 500 + i, body: `第 ${i} 則`, user: { login: 'r' } }));
|
||||
const page2 = [{ id: 999, body: '最後一則', user: { login: 'r' } }];
|
||||
const page1 = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: 500 + i,
|
||||
type: 'comment',
|
||||
body: `第 ${i} 則`,
|
||||
user: { login: 'r' },
|
||||
}));
|
||||
const page2 = [{ id: 999, type: 'comment', body: '最後一則', user: { login: 'r' } }];
|
||||
const reactions = {};
|
||||
for (const c of [...page1, ...page2]) {
|
||||
reactions[`GET /api/v1/repos/${REPO}/issues/comments/${c.id}/reactions`] = { status: 200, body: [] };
|
||||
}
|
||||
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: (req) => ({
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: (req) => ({
|
||||
status: 200,
|
||||
body: req.query.page === '1' ? page1 : page2,
|
||||
}),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -43,13 +43,12 @@ function routes(overrides = {}) {
|
||||
},
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: {
|
||||
status: 200,
|
||||
body: [{ id: REVIEW.commentId, type: 'review', review_id: REVIEW.id, body: REVIEW.body }],
|
||||
body: [
|
||||
{ id: REVIEW.commentId, type: 'review', review_id: REVIEW.id, body: REVIEW.body },
|
||||
{ id: GENERAL.id, type: 'comment', body: GENERAL.body, user: { login: 'r' } },
|
||||
],
|
||||
},
|
||||
[`POST /api/v1/repos/${REPO}/issues/comments/${REVIEW.commentId}/reactions`]: { status: 201, body: {} },
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: {
|
||||
status: 200,
|
||||
body: [{ id: GENERAL.id, body: GENERAL.body, user: { login: 'r' } }],
|
||||
},
|
||||
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: { status: 201, body: { id: 900 } },
|
||||
[`POST /api/v1/repos/${REPO}/issues/comments/${GENERAL.id}/reactions`]: { status: 201, body: {} },
|
||||
[`POST /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: { status: 200, body: { id: 901 } },
|
||||
|
||||
@@ -44,12 +44,16 @@ function routes({ state = 'open', merged = false, draft = false, general = [], h
|
||||
head: head ?? { ref: BRANCH, label: BRANCH },
|
||||
},
|
||||
},
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: {
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: {
|
||||
status: 200,
|
||||
body: general.map((c, i) => ({ id: 100 + i, body: c.body, user: { login: 'reviewer' } })),
|
||||
body: general.map((c, i) => ({
|
||||
id: 100 + i,
|
||||
type: 'comment',
|
||||
body: c.body,
|
||||
user: { login: 'reviewer' },
|
||||
})),
|
||||
},
|
||||
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: { status: 200, body: [] },
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: { status: 200, body: [] },
|
||||
});
|
||||
general.forEach((c, i) => {
|
||||
base[`GET /api/v1/repos/${REPO}/issues/comments/${100 + i}/reactions`] = {
|
||||
|
||||
+6
-226
@@ -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');
|
||||
});
|
||||
|
||||
@@ -146,6 +146,18 @@ test('README 裡的 tea-sdlc 指令逐字拿去跑都認得,不會是寫給人
|
||||
}
|
||||
});
|
||||
|
||||
test('README 交代了安裝會自動驗證,以及驗不過時轉接檔不會被回滾', () => {
|
||||
// 「驗不過但檔案還在」如果沒寫出來,使用者看到 ok:false 的第一個念頭會是自己去清乾淨重裝,
|
||||
// 那正好是這個設計要避免的事
|
||||
const text = readme();
|
||||
const section = text.slice(text.indexOf('### 安裝完成等於驗過能用'), text.indexOf('## 更新 / 移除'));
|
||||
|
||||
assert.ok(section.length > 0, 'README 沒有交代安裝後的驗證');
|
||||
assert.match(section, /ok:false/);
|
||||
assert.match(section, /不.{0,4}回滾|一份都不刪/);
|
||||
assert.match(section, /dry-run/);
|
||||
});
|
||||
|
||||
test('模組邊界表指得到實際存在的檔案', () => {
|
||||
const text = agents();
|
||||
|
||||
|
||||
+11
-508
@@ -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.期間.迄);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fakePrompt, makeFakePlugin } from './helpers/fake-plugin.js';
|
||||
import { tmpRoot } from './helpers/run-script.js';
|
||||
|
||||
const PROMPTS = {
|
||||
'sdlc-analyze': fakePrompt('sdlc-analyze'),
|
||||
'sdlc-plan': fakePrompt('sdlc-plan'),
|
||||
};
|
||||
|
||||
function homeWith(t, platform) {
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
const home = join(tmpRoot, `runtime-home-${platform}`);
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
mkdirSync(join(home, 'work'), { recursive: true });
|
||||
const roots = {
|
||||
'oh-my-pi': ['.omp'],
|
||||
claude: ['.claude'],
|
||||
codex: ['.codex'],
|
||||
kiro: ['.kiro'],
|
||||
};
|
||||
mkdirSync(join(home, ...roots[platform]), { recursive: true });
|
||||
t.after(() => rmSync(home, { recursive: true, force: true }));
|
||||
return home;
|
||||
}
|
||||
|
||||
const runInHome = (plugin, home) => (args) =>
|
||||
plugin.run(args, { env: { HOME: home }, cwd: join(home, 'work') });
|
||||
|
||||
test('sdlc-version 回報版本、實際 tea-sdlc executable 與六個流程', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const { json } = await plugin.run(['sdlc-version']);
|
||||
|
||||
assert.equal(json.ok, true);
|
||||
assert.equal(json.data.name, 'tea-sdlc');
|
||||
assert.equal(json.data.version, '0.0.2');
|
||||
assert.equal(json.data.executable, join(plugin.shim, 'tea-sdlc'));
|
||||
assert.deepEqual(json.data.commands, ['sdlc-analyze', 'sdlc-plan']);
|
||||
});
|
||||
|
||||
test('OMP 使用 ~/.omp/agent/commands 作為 runtime registry,六個 command 全部找到才 pass', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = homeWith(t, 'oh-my-pi');
|
||||
const run = runInHome(plugin, home);
|
||||
const installed = await run(['install']);
|
||||
|
||||
assert.equal(installed.code, 0);
|
||||
assert.ok(existsSync(join(home, '.omp', 'agent', 'commands', 'sdlc-analyze.md')));
|
||||
assert.equal(installed.json.data.verify.platforms[0].runtime.status, 'pass');
|
||||
assert.deepEqual(installed.json.data.verify.platforms[0].runtime.commands, ['sdlc-analyze', 'sdlc-plan']);
|
||||
});
|
||||
|
||||
test('runtime registry 缺少 command 時 verify exit 1 並列出 missing', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = homeWith(t, 'claude');
|
||||
const run = runInHome(plugin, home);
|
||||
await run(['install']);
|
||||
rmSync(join(home, '.claude', 'commands', 'sdlc-plan.md'));
|
||||
|
||||
const result = await run(['verify', '--platform', 'claude']);
|
||||
assert.equal(result.code, 1);
|
||||
assert.equal(result.json.error.code, 'RUNTIME_VERIFY_FAILED');
|
||||
assert.deepEqual(result.json.data.platforms[0].runtime.missing, ['sdlc-plan']);
|
||||
assert.equal(result.json.data.platforms[0].runtime.status, 'fail');
|
||||
});
|
||||
|
||||
test('沒有安全 registry probe 的平台回報 not-supported 而不阻止 install', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = homeWith(t, 'kiro');
|
||||
const result = await runInHome(plugin, home)(['install']);
|
||||
|
||||
assert.equal(result.code, 0);
|
||||
assert.equal(result.json.data.verify.platforms[0].runtime.status, 'not-supported');
|
||||
assert.deepEqual(result.json.data.verify.platforms[0].runtime.failures, []);
|
||||
});
|
||||
+95
-30
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 截止日推算:依相依關係拓撲排序,任一工作包的截止日不得早於它的先決工作包。
|
||||
* 這支腳本不碰 Gitea,純算數字,所以測試只餵檔案、只看 stdout。
|
||||
* 排程計算:日期只落在工作日,並從同一份計畫產出 PERT 與 CPM。
|
||||
* 腳本對外會抓政府日曆;測試用 data URL 固定資料,fallback 測試才改用失效網址。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -8,6 +8,11 @@ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { runScript, tmpRoot } from './helpers/run-script.js';
|
||||
|
||||
/** 測試用的 2026 年日曆:12 月 25 日是已知假日,其他日期只由週末判斷。 */
|
||||
const CALENDAR = `data:text/csv,${encodeURIComponent(
|
||||
'西元日期,星期,是否放假,備註\n20261224,四,0,\n20261225,五,2,測試假日\n20261228,一,0,',
|
||||
)}`;
|
||||
|
||||
/** 把一份排程計畫寫成暫存檔,回傳路徑 */
|
||||
function writePlan(plan) {
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
@@ -17,14 +22,17 @@ function writePlan(plan) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const run = (plan, args = []) =>
|
||||
runScript('schedule.js', ['--plan-file', writePlan(plan), ...args]);
|
||||
const run = (plan, args = [], opts = {}) =>
|
||||
runScript('schedule.js', ['--plan-file', writePlan(plan), ...args], {
|
||||
...opts,
|
||||
env: { TEA_SDLC_CALENDAR_URL: CALENDAR, ...opts.env },
|
||||
});
|
||||
|
||||
/** 依 index 取出算出來的截止日 */
|
||||
const dueOf = (json) =>
|
||||
Object.fromEntries(json.data.schedule.map((wp) => [wp.index, wp.dueDate]));
|
||||
|
||||
test('沒有相依時,每顆各自從起始日加上自己的人天', async () => {
|
||||
test('沒有相依時,每顆各自從起始日加上工作日人天', async () => {
|
||||
const { code, json } = await run({
|
||||
startDate: '2026-09-21',
|
||||
workPackages: [
|
||||
@@ -34,10 +42,10 @@ test('沒有相依時,每顆各自從起始日加上自己的人天', async ()
|
||||
});
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.deepEqual(dueOf(json), { 1: '2026-09-23', 2: '2026-09-26' });
|
||||
assert.deepEqual(dueOf(json), { 1: '2026-09-23', 2: '2026-09-28' });
|
||||
});
|
||||
|
||||
test('有先決時,截止日從先決的截止日往後算', async () => {
|
||||
test('有先決時,截止日從先決的工作日截止日往後算', async () => {
|
||||
const { json } = await run({
|
||||
startDate: '2026-09-21',
|
||||
workPackages: [
|
||||
@@ -46,7 +54,7 @@ test('有先決時,截止日從先決的截止日往後算', async () => {
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(dueOf(json), { 1: '2026-09-23', 2: '2026-09-26' });
|
||||
assert.deepEqual(dueOf(json), { 1: '2026-09-23', 2: '2026-09-28' });
|
||||
});
|
||||
|
||||
test('多個先決時取最晚的那一個當起點', async () => {
|
||||
@@ -59,28 +67,31 @@ test('多個先決時取最晚的那一個當起點', async () => {
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(dueOf(json)[3], '2026-10-01', '要等最晚的乙做完才開始');
|
||||
assert.equal(dueOf(json)[3], '2026-10-05', '要等最晚的乙做完才開始');
|
||||
});
|
||||
|
||||
test('任一工作包的截止日都不早於它的先決', async () => {
|
||||
test('週末與年度假日都不會被算作工作日', async () => {
|
||||
const { json } = await run({
|
||||
startDate: '2026-09-21',
|
||||
workPackages: [
|
||||
{ index: 1, title: '甲', days: 3 },
|
||||
{ index: 2, title: '乙', days: 1, depends: [1] },
|
||||
{ index: 3, title: '丙', days: 1, depends: [2] },
|
||||
{ index: 4, title: '丁', days: 4, depends: [1] },
|
||||
{ index: 5, title: '戊', days: 2, depends: [3, 4] },
|
||||
],
|
||||
startDate: '2026-12-24',
|
||||
workPackages: [{ index: 1, title: '跨假日', days: 2 }],
|
||||
});
|
||||
|
||||
const due = dueOf(json);
|
||||
const depends = { 2: [1], 3: [2], 4: [1], 5: [3, 4] };
|
||||
for (const [index, prerequisites] of Object.entries(depends)) {
|
||||
for (const p of prerequisites) {
|
||||
assert.ok(due[index] > due[p], `#${index} 的截止日不該早於先決 #${p}`);
|
||||
}
|
||||
}
|
||||
assert.equal(dueOf(json)[1], '2026-12-29');
|
||||
});
|
||||
|
||||
test('日曆抓不到時只跳過週末並印出警告', async () => {
|
||||
const { code, json, stderr } = await run(
|
||||
{
|
||||
startDate: '2026-12-24',
|
||||
workPackages: [{ index: 1, title: 'fallback', days: 2 }],
|
||||
},
|
||||
[],
|
||||
{ env: { TEA_SDLC_CALENDAR_URL: 'http://127.0.0.1:1/unavailable.csv' } },
|
||||
);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(dueOf(json)[1], '2026-12-28');
|
||||
assert.match(stderr, /警告.*週末/);
|
||||
});
|
||||
|
||||
test('輸出的順序即拓撲順序,先決一定排在後續之前', async () => {
|
||||
@@ -96,6 +107,54 @@ test('輸出的順序即拓撲順序,先決一定排在後續之前', async ()
|
||||
assert.deepEqual(json.data.order, [1, 2, 3]);
|
||||
});
|
||||
|
||||
test('CPM 會計算 ES、EF、LS、LF、浮時與關鍵路徑', async () => {
|
||||
const { json } = await run({
|
||||
startDate: '2026-09-21',
|
||||
workPackages: [
|
||||
{ index: 1, title: '長支線', days: 2 },
|
||||
{ index: 2, title: '短支線', days: 1 },
|
||||
{ index: 3, title: '匯合', days: 1, depends: [1, 2] },
|
||||
],
|
||||
});
|
||||
|
||||
const byIndex = Object.fromEntries(json.data.schedule.map((item) => [item.index, item]));
|
||||
assert.deepEqual(
|
||||
{ ES: byIndex[2].ES, EF: byIndex[2].EF, LS: byIndex[2].LS, LF: byIndex[2].LF, float: byIndex[2].float },
|
||||
{ ES: '2026-09-21', EF: '2026-09-22', LS: '2026-09-22', LF: '2026-09-23', float: 1 },
|
||||
);
|
||||
assert.deepEqual(json.data.criticalPath, [1, 3]);
|
||||
assert.equal(byIndex[1].critical, true);
|
||||
assert.equal(byIndex[2].critical, false);
|
||||
});
|
||||
|
||||
test('PERT 會計算工作包與專案的期望值、變異數和標準差', async () => {
|
||||
const { json } = await run({
|
||||
startDate: '2026-09-21',
|
||||
workPackages: [
|
||||
{
|
||||
index: 1,
|
||||
title: '三點估算',
|
||||
optimistic: 2,
|
||||
mostLikely: 3,
|
||||
pessimistic: 4,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const item = json.data.schedule[0];
|
||||
assert.deepEqual(item.pert, {
|
||||
optimistic: 2,
|
||||
mostLikely: 3,
|
||||
pessimistic: 4,
|
||||
expected: 3,
|
||||
variance: 1 / 9,
|
||||
});
|
||||
assert.equal(item.expectedDays, 3);
|
||||
assert.equal(json.data.project.expectedDuration, 3);
|
||||
assert.equal(json.data.project.variance, 1 / 9);
|
||||
assert.equal(json.data.project.standardDeviation, 1 / 3);
|
||||
});
|
||||
|
||||
test('相依成環時中止,並指出環上的成員', async () => {
|
||||
const { code, json } = await run({
|
||||
startDate: '2026-09-21',
|
||||
@@ -161,7 +220,7 @@ test('計畫檔不是合法 JSON 時中止', async () => {
|
||||
assert.equal(json.error.code, 'BAD_PLAN');
|
||||
});
|
||||
|
||||
test('輸出保留標題與人天,呼叫端不必回頭對照計畫檔', async () => {
|
||||
test('輸出保留標題、人天與日期欄位', async () => {
|
||||
const { json } = await run({
|
||||
startDate: '2026-09-21',
|
||||
workPackages: [{ index: 7, title: '建立抽取契約', days: 3 }],
|
||||
@@ -172,13 +231,20 @@ test('輸出保留標題與人天,呼叫端不必回頭對照計畫檔', async
|
||||
title: '建立抽取契約',
|
||||
days: 3,
|
||||
dueDate: '2026-09-24',
|
||||
ES: '2026-09-21',
|
||||
EF: '2026-09-24',
|
||||
LS: '2026-09-21',
|
||||
LF: '2026-09-24',
|
||||
float: 0,
|
||||
critical: true,
|
||||
expectedDays: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('這支腳本完全不碰 Gitea:沒有登入資訊也能算', async () => {
|
||||
const { code } = await runScript(
|
||||
'schedule.js',
|
||||
['--plan-file', writePlan({ startDate: '2026-09-21', workPackages: [{ index: 1, title: '甲', days: 1 }] })],
|
||||
const { code } = await run(
|
||||
{ startDate: '2026-09-21', workPackages: [{ index: 1, title: '甲', days: 1 }] },
|
||||
[],
|
||||
{ env: { TEA_SDLC_CONFIG: '/nonexistent/tea.yml' } },
|
||||
);
|
||||
|
||||
@@ -186,7 +252,6 @@ test('這支腳本完全不碰 Gitea:沒有登入資訊也能算', async () =>
|
||||
});
|
||||
|
||||
test('同一個 index 出現兩次時中止,不把兩份定義混在一起算', async () => {
|
||||
// 混出來的結果會是:相依看後者、標題與人天取前者,而且完全不報錯
|
||||
const { code, json } = await run({
|
||||
startDate: '2026-09-21',
|
||||
workPackages: [
|
||||
|
||||
@@ -1,112 +1,99 @@
|
||||
/**
|
||||
* sdlc-analyze 的交付物:四份可行性檢查清單與流程正本。
|
||||
* 這一段不寫入 Gitea,所以沒有腳本——交付的就是這些文件本身。
|
||||
* /sdlc-analyze 的流程正本。
|
||||
*
|
||||
* 這些測試鎖定交付文件確認與排程前置條件;漏掉其中任一個閘門,
|
||||
* agent 就可能在使用者尚未確認時建立工作包或排程。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { assertNeutralPrompt, readPrompt, readReference } from './helpers/prompt-doc.js';
|
||||
import {
|
||||
assertNeutralPrompt,
|
||||
assertPromptListsSections,
|
||||
readPrompt,
|
||||
} 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' },
|
||||
const delivery = prompt.slice(prompt.indexOf('### 交付文件判斷'), prompt.indexOf('## 第二段'));
|
||||
const packages = prompt.slice(prompt.indexOf('## 第二段'), prompt.indexOf('## 第三段'));
|
||||
const schedule = prompt.slice(prompt.indexOf('## 第三段'), prompt.indexOf('## 邊界'));
|
||||
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
|
||||
const TYPES = [
|
||||
'需求描述概要',
|
||||
'WBS(工作分解結構)',
|
||||
'流程圖',
|
||||
'甘特圖',
|
||||
'PERT 圖',
|
||||
'關鍵路徑圖',
|
||||
'API 契約文件',
|
||||
];
|
||||
|
||||
// ── 規則正本 ───────────────────────────────────────────────────────
|
||||
// ── 交付文件確認 ────────────────────────────────────────────────
|
||||
|
||||
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('正本讀取七種交付文件規則,且依規則順序逐項確認', () => {
|
||||
assert.match(delivery, /references\/delivery-types\.md/);
|
||||
assertPromptListsSections(delivery, TYPES);
|
||||
assert.match(delivery, /依序逐一詢問/);
|
||||
assert.match(delivery, /不得把七種文件合併成一次確認/);
|
||||
});
|
||||
|
||||
test('每份清單都交代了「問題怎麼問」,不只列檢查項', () => {
|
||||
for (const { kind, file } of CHECKS) {
|
||||
assert.match(readReference(file), /## 問題怎麼問/, `${kind}清單缺少提問指引`);
|
||||
}
|
||||
test('每種文件確認都保留必要內容、產出位置與 ELI5 規則', () => {
|
||||
assert.match(delivery, /必要內容骨架/);
|
||||
assert.match(delivery, /產出位置/);
|
||||
assert.match(delivery, /ELI5 變體規則/);
|
||||
});
|
||||
|
||||
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('交付文件未確認或遭拒時,所有後續副作用都被阻擋', () => {
|
||||
assert.match(delivery, /未確認或拒絕任何一項時,立即停止/);
|
||||
assert.match(delivery, /不建立工作包、不排程、不建立相依/);
|
||||
assert.match(delivery, /不寫入 Milestone、看板或其他後續資料/);
|
||||
});
|
||||
|
||||
// ── 流程正本 ───────────────────────────────────────────────────────
|
||||
// ── PERT 與工作包優先 ────────────────────────────────────────────
|
||||
|
||||
test('正本平台中立,description 前綴正確', () => {
|
||||
test('PERT 的 O、M、P 分別逐題確認,且不能用單一人天替代', () => {
|
||||
const pert = prompt.slice(prompt.indexOf('### PERT 三點估算'), prompt.indexOf('## 第二段'));
|
||||
assert.match(pert, /樂觀時間(O)/);
|
||||
assert.match(pert, /最可能時間(M)/);
|
||||
assert.match(pert, /悲觀時間(P)/);
|
||||
assert.match(pert, /分別逐題詢問/);
|
||||
assert.match(pert, /不得用單一人天估算代替/);
|
||||
assert.match(pert, /三個值都確認後才納入排程資料/);
|
||||
});
|
||||
|
||||
test('交付文件工作包優先,但以先決關係的拓撲順序為準', () => {
|
||||
assert.match(packages, /先放已確認的交付文件工作包,再放純程式碼工作包/);
|
||||
assert.match(packages, /不違反先決關係/);
|
||||
assert.match(packages, /拓撲順序/);
|
||||
});
|
||||
|
||||
test('每個工作包只承擔一種交付並保留驗收所需欄位', () => {
|
||||
assert.match(packages, /每顆工作包只做一種交付/);
|
||||
assert.match(packages, /文件類型、必要內容、產出位置與驗收方式/);
|
||||
assert.match(packages, /對應已確認交付項目的待辦置於第一項/);
|
||||
});
|
||||
|
||||
// ── 建立與排程邊界 ────────────────────────────────────────────────
|
||||
test('重跑需求會以既有議題與標題查重,不建立重複工作包', () => {
|
||||
assert.match(packages, /重跑同一需求時,先以既有議題與標題查重/);
|
||||
assert.match(packages, /不建立重複工作包/);
|
||||
});
|
||||
|
||||
test('工作包建立與排程仍要求 dry-run,且排程在相依確認後', () => {
|
||||
assert.match(packages, /issue-create\.js --dry-run/);
|
||||
assert.match(schedule, /所有工作包建立且相依關係確認後/);
|
||||
assert.match(schedule, /schedule\.js/);
|
||||
assert.match(schedule, /各腳本先 dry-run,再實跑/);
|
||||
});
|
||||
|
||||
test('共識、交付確認與 PERT 完成前不建立任何後續資料', () => {
|
||||
assert.match(boundary, /共識摘要、交付文件確認與 PERT 三點估算完成前/);
|
||||
assert.match(boundary, /不建立任何工作包、不排程、不寫入後續資料/);
|
||||
});
|
||||
|
||||
test('API 契約不寫入目標專案,且流程維持終端摘要與平台中立', () => {
|
||||
assert.match(delivery, /API 契約文件只能交付預覽或使用者確認的位置/);
|
||||
assert.match(delivery, /禁止寫入目標專案 repo/);
|
||||
assert.match(prompt, /摘要只印終端,不寫入 Gitea/);
|
||||
assert.match(boundary, /不把 API 契約文件寫入目標專案 repo/);
|
||||
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', 'timer']) {
|
||||
assert.equal(analysis.includes(writer), false, `第一段不該出現寫入型腳本 ${writer}`);
|
||||
}
|
||||
});
|
||||
|
||||
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'), /還沒有工作包/);
|
||||
});
|
||||
|
||||
+45
-307
@@ -1,332 +1,70 @@
|
||||
/**
|
||||
* 正本第一段「領取與開工準備」的規則。
|
||||
*
|
||||
* 這些檔案是文件不是程式,但它們是指令實際交付的東西:決策表寫錯,被鎖擋下來的人
|
||||
* 就會得到錯的下一步;邊界寫漏,第一段就會去做後面幾段的事。靠人記不牢,用測試釘住。
|
||||
*/
|
||||
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('## 邊界'));
|
||||
const 分流 = prompt.slice(prompt.indexOf('## 交付文件待辦分流'), prompt.indexOf('## 實作與交付'));
|
||||
|
||||
// ── 正本基本契約 ───────────────────────────────────────────────────
|
||||
|
||||
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(prompt, /scripts\/wp-extract\.js/);
|
||||
assert.match(prompt, /未處理留言數/);
|
||||
assert.match(prompt, /重新抽取一次/);
|
||||
});
|
||||
|
||||
test('錶等工作樹建好之後才起,並說明為什麼', () => {
|
||||
assert.match(phase1, /錶不在這一步起/, '領取那一步要明講錶還沒起');
|
||||
assert.match(phase1, /什麼都沒做的時間/, '要說明為什麼後移:失敗的領取不該留下憑空的工時');
|
||||
test('保留 worktree、依賴、驗收與 PR 的既有交付流程', () => {
|
||||
assert.match(prompt, /依工作包 `repos` 逐一準備 worktree/);
|
||||
assert.match(prompt, /立即勾選對應驗收/);
|
||||
assert.match(prompt, /scripts\/pr-create\.js --dry-run/);
|
||||
});
|
||||
|
||||
test('工作樹一律建立,建不起來就中止而不是退回原地切分支', () => {
|
||||
assert.match(phase1, /一律建立,沒有例外/);
|
||||
assert.match(phase1, /不要改成在原地切分支/);
|
||||
assert.match(phase1, /以為自己在隔離環境裡/, '要說明靜默降級的後果');
|
||||
// ── 交付文件分流 ───────────────────────────────────────────────────
|
||||
|
||||
test('交付文件待辦與程式碼待辦走不同流程', () => {
|
||||
assert.match(分流, /判定為程式碼待辦或交付文件待辦/);
|
||||
assert.match(分流, /不可共用無差別的實作流程/);
|
||||
assert.match(分流, /references\/delivery-types\.md/);
|
||||
});
|
||||
|
||||
test('實作要在工作樹裡做,路徑從輸出取得', () => {
|
||||
assert.match(phase1, /worktree/, '工作樹路徑印在哪個欄位要講');
|
||||
assert.match(phase1, /都在那棵工作樹裡做/);
|
||||
test('每份文件產出前逐一列骨架並一次確認一題', () => {
|
||||
assert.match(分流, /每一份交付文件都要分開處理/);
|
||||
assert.match(分流, /必要內容骨架、已知來源、未決事項、建議與理由/);
|
||||
assert.match(分流, /一次只問一題確認內容是否齊全/);
|
||||
assert.match(分流, /未確認的文件不可標成已交付/);
|
||||
});
|
||||
|
||||
test('工作樹是乾淨的,且機密檔案不會被複製過去', () => {
|
||||
assert.match(phase1, /沒有安裝依賴/);
|
||||
assert.match(phase1, /安裝指令/);
|
||||
assert.match(phase1, /\.env/);
|
||||
test('預覽能力以可開啟可分享能力判斷,具備時直接產出', () => {
|
||||
assert.match(分流, /即時發佈成可開啟、可分享的預覽頁/);
|
||||
assert.match(分流, /具備能力後直接產出預覽/);
|
||||
assert.match(分流, /回報實際位置與內容摘要/);
|
||||
});
|
||||
|
||||
test('未處理留言不是 0 時要先停下來提示整併', () => {
|
||||
assert.match(phase1, /未處理留言數/);
|
||||
assert.match(phase1, /先停下來/);
|
||||
assert.match(phase1, /sdlc-sync/);
|
||||
test('預覽能力不足時一次一題詢問,建議由情境推導', () => {
|
||||
assert.match(分流, /無法證明具備能力、預覽不可開啟或不可分享/);
|
||||
assert.match(分流, /一次只問一題/);
|
||||
assert.match(分流, /依文件、來源與限制提出建議及理由/);
|
||||
assert.match(分流, /保留手動輸入/);
|
||||
assert.match(分流, /不替使用者決定交付方式/);
|
||||
assert.match(分流, /不要靠固定清單或猜測環境/);
|
||||
assert.match(prompt, /不產生固定選項清單/);
|
||||
});
|
||||
|
||||
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 自由發揮');
|
||||
// ── ELI5 與契約邊界 ─────────────────────────────────────────────────
|
||||
|
||||
test('ELI5 保留技術意義並把圖表改成圖片式視覺', () => {
|
||||
assert.match(分流, /保留原文件的範圍、順序、相依、例外與驗收意義/);
|
||||
assert.match(分流, /不刪除技術限制/);
|
||||
assert.match(分流, /重新繪製成容易閱讀的圖片式視覺/);
|
||||
assert.match(分流, /不把 Mermaid 原碼當成交付物/);
|
||||
});
|
||||
|
||||
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, /工時記錯地方/);
|
||||
});
|
||||
|
||||
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 的類型與描述/);
|
||||
test('API 契約只在預覽或確認位置交付,不寫入目標 repo', () => {
|
||||
assert.match(分流, /API 契約文件只能交付在預覽位置或使用者確認的位置/);
|
||||
assert.match(分流, /不得寫入目標專案 repo/);
|
||||
assert.match(分流, /不捏造網址/);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* /sdlc-fix 的流程正本。
|
||||
*
|
||||
* 這一段有三件事只有正本做得到,腳本擋不住:分類必改/建議、不確定時停下來問、
|
||||
* 以及最後那則摘要。寫漏任何一件,reviewer 的意見就會被靜靜跳過——而那正是這個指令
|
||||
* 存在的理由。
|
||||
* 這一段有四件事只有正本做得到,腳本擋不住:把三種輸入判到對的路上、分類必改/建議、
|
||||
* 不確定時停下來問、以及最後那則摘要。寫漏任何一件,reviewer 的意見就會被靜靜跳過——
|
||||
* 而那正是這個指令存在的理由。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -11,10 +11,114 @@ import { assertNeutralPrompt, readPrompt } from './helpers/prompt-doc.js';
|
||||
|
||||
const prompt = readPrompt('sdlc-fix');
|
||||
const steps = prompt.slice(prompt.indexOf('## 1.'), prompt.indexOf('## 邊界'));
|
||||
/** 定位那一步,與後面處理留言的步驟分開看 */
|
||||
const locate = steps.slice(0, steps.indexOf('## 2.'));
|
||||
|
||||
test('第一步先看 PR 現況,終止狀態就停下來不白做工', () => {
|
||||
/** 框出一個步驟的範圍,讓各步驟分開看 */
|
||||
const step = (from, to) => steps.slice(steps.indexOf(from), steps.indexOf(to));
|
||||
|
||||
/** 判定輸入是哪一種 */
|
||||
const 判定 = step('## 1.', '## 2.');
|
||||
/** 議題交棒那一步 */
|
||||
const 交棒 = step('## 2.', '## 3.');
|
||||
/** 工作包議題與需求議題各自的小節 */
|
||||
const 工作包路 = 交棒.slice(交棒.indexOf('### 工作包議題'), 交棒.indexOf('### 需求議題'));
|
||||
const 需求路 = 交棒.slice(交棒.indexOf('### 需求議題'));
|
||||
/** 定位工作樹那一步,與後面處理留言的步驟分開看 */
|
||||
const locate = step('## 3.', '## 4.');
|
||||
|
||||
// ── 輸入判定:三種輸入走三條路 ─────────────────────────────────────
|
||||
|
||||
test('輸入接受 PR 編號或議題編號', () => {
|
||||
const 輸入 = prompt.slice(prompt.indexOf('## 輸入'), prompt.indexOf('## 1.'));
|
||||
assert.match(輸入, /PR 編號或議題編號/);
|
||||
});
|
||||
|
||||
test('PR 與議題的分界用既有的留言讀取契約,不另發明判準', () => {
|
||||
assert.match(判定, /pr-comments\.js/);
|
||||
assert.match(判定, /類型/);
|
||||
assert.match(判定, /每個 PR 都是議題/, '要說出為什麼只能從議題那一端問');
|
||||
assert.match(判定, /不另發明判準/);
|
||||
});
|
||||
|
||||
test('兩種議題的分界是工作包抽取的母議題欄位', () => {
|
||||
assert.match(判定, /wp-extract\.js/);
|
||||
assert.match(判定, /需求議題.*母議題|母議題/);
|
||||
assert.match(判定, /解析得出編號的就是工作包議題/);
|
||||
assert.match(判定, /解析不出來的就當需求\s*議題/);
|
||||
});
|
||||
|
||||
test('三種判定各自寫明下一步,不留一種讓 agent 自由發揮', () => {
|
||||
const rows = 判定.slice(判定.indexOf('| 判定'));
|
||||
assert.match(rows, /`類型` 為 `PR`/);
|
||||
assert.match(rows, /抽得出 `需求議題`/);
|
||||
assert.match(rows, /抽不出 `需求議題`/);
|
||||
});
|
||||
|
||||
// ── 議題:交棒給 /sdlc-feat ────────────────────────────────────────
|
||||
|
||||
test('工作包議題交棒給 /sdlc-feat,指的是它的正本而不是複述它', () => {
|
||||
assert.match(工作包路, /sdlc-feat/);
|
||||
assert.match(工作包路, /prompts\/sdlc-feat\.md/);
|
||||
assert.match(工作包路, /不要把它的步驟搬過來重講一遍/);
|
||||
assert.match(工作包路, /第二份正本/, '要說出為什麼不抄');
|
||||
});
|
||||
|
||||
test('交棒不要求使用者重打指令,並指名沿用既有的接回機制', () => {
|
||||
assert.match(交棒, /sdlc-sync/);
|
||||
assert.match(交棒, /接回|接下去/);
|
||||
assert.match(交棒, /不要求使用者重打\s*指令/);
|
||||
});
|
||||
|
||||
test('工作包議題不在這裡先整併留言:那一步 /sdlc-feat 自己會做', () => {
|
||||
assert.match(工作包路, /未處理留言數/);
|
||||
assert.match(工作包路, /不必在這裡先做/);
|
||||
});
|
||||
|
||||
test('正本裡不出現 /sdlc-feat 步驟的複製', () => {
|
||||
for (const 腳本 of ['claim.js', 'branch-prep.js', 'timer.js', 'commit-split.js', 'pr-create.js']) {
|
||||
assert.equal(prompt.includes(腳本), false, `這是 /sdlc-feat 的步驟,不該被抄進來:${腳本}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('需求議題先讓 /sdlc-sync 整併,再談改不改碼', () => {
|
||||
assert.match(需求路, /sdlc-sync/);
|
||||
assert.match(需求路, /prompts\/sdlc-sync\.md/);
|
||||
assert.match(需求路, /絕大多數是決策討論/, '要說出為什麼先 sync');
|
||||
assert.ok(
|
||||
需求路.indexOf('sdlc-sync') < 需求路.indexOf('wp-list.js'),
|
||||
'整併要排在選工作包之前',
|
||||
);
|
||||
});
|
||||
|
||||
test('本來有留言、整併完沒有改碼要求就停下來,不硬找一顆工作包來改', () => {
|
||||
assert.match(需求路, /本來有留言,而整併完一則要改碼的都不剩,就到此為止/);
|
||||
assert.match(需求路, /根本不會觸發/);
|
||||
});
|
||||
|
||||
test('本來就沒有未整併留言時照樣列出工作包,不當成沒事可做', () => {
|
||||
assert.match(需求路, /`未處理數` 本來就是 0/);
|
||||
assert.match(需求路, /這一步整個跳過/);
|
||||
assert.match(需求路, /或一開始就沒有留言要整併/, '列清單的入口要同時收得下這一種');
|
||||
});
|
||||
|
||||
test('有改碼要求時列出底下的工作包讓使用者挑,不報錯把事推回去', () => {
|
||||
assert.match(需求路, /wp-list\.js/);
|
||||
assert.match(需求路, /--requirement/);
|
||||
assert.match(需求路, /不要因為「輸入不是工作包」就報錯/);
|
||||
assert.match(需求路, /一次問一題/);
|
||||
assert.match(需求路, /手動輸入/);
|
||||
});
|
||||
|
||||
test('清單為空時照實說,並指出該跑的是哪一支', () => {
|
||||
assert.match(需求路, /清單是空的/);
|
||||
assert.match(需求路, /sdlc-analyze/);
|
||||
});
|
||||
|
||||
test('挑定之後一樣交棒給 /sdlc-feat,留言只帶脈絡不搬走', () => {
|
||||
assert.match(需求路, /交棒給 `\/sdlc-feat`/);
|
||||
assert.match(需求路, /不搬走/);
|
||||
});
|
||||
|
||||
test('PR 的第一步先看現況,終止狀態就停下來不白做工', () => {
|
||||
assert.match(locate, /pr-watch\.js/);
|
||||
assert.match(locate, /terminal/);
|
||||
assert.match(locate, /不要繼續處理留言/);
|
||||
@@ -118,7 +222,7 @@ test('一次問一題,選項含手動輸入', () => {
|
||||
});
|
||||
|
||||
test('該問的情況有列舉,不是一句「不確定就問」', () => {
|
||||
const section = steps.slice(steps.indexOf('## 4.'), steps.indexOf('## 5.'));
|
||||
const section = step('## 6.', '## 7.');
|
||||
const bullets = section.match(/^- /gm) ?? [];
|
||||
assert.ok(bullets.length >= 3, `該問的情況要列得出來,只找到 ${bullets.length} 條`);
|
||||
assert.match(section, /推了新 commit/, '位置對不上是最常見的一種,要點名');
|
||||
@@ -159,7 +263,7 @@ test('寫入前要求先試跑', () => {
|
||||
// ── 摘要 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('摘要要逐則列出,並單獨點出沒有記號的那幾則', () => {
|
||||
const section = steps.slice(steps.indexOf('## 6.'));
|
||||
const section = steps.slice(steps.indexOf('## 8.')); // 最後一步,到結尾為止
|
||||
assert.match(section, /逐則一行/);
|
||||
assert.match(section, /沒有留下記號的那幾則/);
|
||||
assert.match(section, /以為它們被跳過/, '要說出為什麼得單獨列');
|
||||
|
||||
@@ -1,101 +1,36 @@
|
||||
/**
|
||||
* 流程正本與輸出模板的結構驗證。
|
||||
*
|
||||
* 這兩份是檔案而非程式,但它們是 #4 實際交付的東西:模板段落順序決定了下游
|
||||
* issue-extract 解析得到什麼,正本的平台中立性決定了轉接檔能不能一份寫到底。
|
||||
* 用測試釘住,比靠人記得住可靠。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
assertDiagramPlaceholderOnly,
|
||||
assertNeutralPrompt,
|
||||
assertPromptListsSections,
|
||||
assertTemplateSections,
|
||||
readPrompt,
|
||||
readTemplate,
|
||||
} from './helpers/prompt-doc.js';
|
||||
|
||||
const template = readTemplate('requirement-issue');
|
||||
const prompt = readPrompt('sdlc-plan');
|
||||
const template = readTemplate('requirement-issue');
|
||||
const SECTIONS = ['總覽', '背景', '目標', '非目標', '領域名詞表', '文件', '驗收標準', '影響範圍', '未決事項'];
|
||||
|
||||
/** 需求議題的九個段落,順序即議題裡的順序 */
|
||||
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 前綴正確', () => {
|
||||
test('plan prompt 使用文件段落並維持繁體中文', () => {
|
||||
assertNeutralPrompt(prompt, 'sdlc-plan');
|
||||
assert.match(prompt, /領域名詞表、文件、驗收標準/);
|
||||
assert.match(prompt, /全程使用繁體中文/);
|
||||
assert.doesNotMatch(prompt, /流程圖/);
|
||||
});
|
||||
|
||||
test('正本交代了三種輸入都要能吃', () => {
|
||||
for (const kind of ['自由文字', '規格檔', '議題編號']) {
|
||||
assert.match(prompt, new RegExp(kind), `正本要說明輸入可為${kind}`);
|
||||
}
|
||||
test('文件段落只允許 analyze 佔位或抽象節點與邊', () => {
|
||||
assert.match(prompt, /`待 \/sdlc-analyze 產生`/);
|
||||
assert.match(prompt, /抽象節點與邊的文字描述/);
|
||||
assert.match(prompt, /不寫具體圖形語法/);
|
||||
assert.match(prompt, /禁止產生 HTML、SVG、manifest、截圖、附件或任何平台 preview/);
|
||||
});
|
||||
|
||||
test('正本明令缺漏資訊要逐項問,不得自行編造', () => {
|
||||
assert.match(prompt, /一次問一題|逐項詢問/);
|
||||
assert.match(prompt, /不得(自行|替使用者)?(編造|填入)/);
|
||||
test('plan 重跑沿用同標題議題,不建立重複議題', () => {
|
||||
assert.match(prompt, /重跑以標題查重,不建立重複議題/);
|
||||
});
|
||||
|
||||
test('正本釘住 Mermaid flowchart 的節點上限與字數上限', () => {
|
||||
assert.match(prompt, /flowchart/);
|
||||
assert.match(prompt, /12/);
|
||||
assert.match(prompt, /8\s*字/);
|
||||
assert.match(prompt, /拆(成多)?圖|不畫/);
|
||||
});
|
||||
|
||||
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('模板不把 mermaid 圍欄寫死:不畫圖時才不會留下渲染失敗的空區塊', () => {
|
||||
assertDiagramPlaceholderOnly(template, '流程圖', '驗收標準');
|
||||
});
|
||||
|
||||
test('正本交代了畫與不畫兩種情況各該填什麼', () => {
|
||||
assert.match(prompt, /```mermaid/);
|
||||
assert.match(prompt, /不要加圍欄|不加圍欄/);
|
||||
test('需求議題模板與 plan prompt 使用相同九段落', () => {
|
||||
assertTemplateSections(template, SECTIONS);
|
||||
assert.match(template, /\{\{文件\}\}/);
|
||||
assert.doesNotMatch(template, /^## 流程圖$/m);
|
||||
});
|
||||
|
||||
@@ -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, /週報沒有分段/);
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* 起錶。
|
||||
*
|
||||
* 錶是工時報表的唯一來源,所以這一支的價值全在「什麼時候不該起」:工作樹還沒建好
|
||||
* 不該起(那由流程的順序保證),自己的錶已經跑在別顆議題上更不該起——那會把兩顆
|
||||
* 工作包的時間攪在一起。停錶一律由使用者自己來,這裡不提供。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { runScript } from './helpers/run-script.js';
|
||||
import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
const INDEX = 40;
|
||||
|
||||
/** 議題上跑著的碼錶長什麼樣 */
|
||||
const stopwatchOn = (index, repo = REPO) => ({
|
||||
issue_index: index,
|
||||
repo_owner_name: repo.split('/')[0],
|
||||
repo_name: repo.split('/')[1],
|
||||
});
|
||||
|
||||
function routes(overrides = {}, { stopwatches = [] } = {}) {
|
||||
return healthyRoutes(REPO, {
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
|
||||
status: 200,
|
||||
body: {
|
||||
number: INDEX,
|
||||
title: '以 worktree 建立工作包分支並備妥隔離環境',
|
||||
html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/${INDEX}`,
|
||||
},
|
||||
},
|
||||
'GET /api/v1/user/stopwatches': { status: 200, body: stopwatches },
|
||||
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/start`]: { status: 201, body: {} },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options));
|
||||
|
||||
const run = (args, stub) =>
|
||||
runScript('timer.js', ['--repo', REPO, '--index', String(INDEX), ...args], { env: envFor(stub) });
|
||||
|
||||
/** 會改動 Gitea 的請求;前置檢查打在 issues/0 的探針不算(見 lib 的 checkIssueWrite) */
|
||||
const writes = (stub) =>
|
||||
stub.requests.filter((r) => r.method !== 'GET').filter((r) => !r.path.endsWith('/issues/0'));
|
||||
|
||||
test('沒有錶在跑時起錶,並回報起在哪一顆上', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run([], stub);
|
||||
|
||||
assert.equal(code, 0, json.error?.message);
|
||||
assert.equal(json.data.碼錶中, true);
|
||||
assert.equal(json.data.index, INDEX);
|
||||
assert.deepEqual(
|
||||
writes(stub).map((r) => `${r.method} ${r.path}`),
|
||||
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/start`],
|
||||
);
|
||||
});
|
||||
|
||||
test('錶已經跑在這顆議題上時什麼都不做,重跑不會把計時打斷', async (t) => {
|
||||
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX)] });
|
||||
|
||||
const { code, json } = await run([], stub);
|
||||
|
||||
assert.equal(code, 0, json.error?.message);
|
||||
assert.equal(json.data.碼錶中, true);
|
||||
assert.equal(json.data.已在計時, true, '要如實說這顆本來就在計時,不要假裝是這次起的');
|
||||
assert.deepEqual(writes(stub), [], '重新起錶會把已經累積的時間切成兩段');
|
||||
});
|
||||
|
||||
test('錶跑在別顆議題上時擋下,並指出是哪一顆', async (t) => {
|
||||
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(7)] });
|
||||
|
||||
const { code, json } = await run([], stub);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'STOPWATCH_ON_OTHER_ISSUE');
|
||||
assert.match(json.error.message, /#7/, '忘了停掉的是哪一顆,要指名');
|
||||
assert.deepEqual(writes(stub), [], '擋下來就不該寫進任何東西');
|
||||
});
|
||||
|
||||
test('別的 repo 上的同號碼錶也算自己有錶在跑', async (t) => {
|
||||
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX, 'plugins/別的專案')] });
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
assert.equal(json.error.code, 'STOPWATCH_ON_OTHER_ISSUE');
|
||||
assert.match(json.error.message, /別的專案/);
|
||||
});
|
||||
|
||||
test('不代替使用者停錶:訊息要說清楚下一步是他自己去停', async (t) => {
|
||||
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(7)] });
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
assert.match(json.error.message, /停/);
|
||||
assert.match(
|
||||
json.error.message,
|
||||
/不會動到任何既有的工作樹/,
|
||||
'碼錶只管時間、工作樹只管檔案;不講清楚,使用者會以為停錶等於放棄那顆工作包',
|
||||
);
|
||||
});
|
||||
|
||||
test('議題不存在時回可區分的錯誤碼', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 404, body: { message: 'not found' } },
|
||||
});
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
assert.equal(json.error.code, 'ISSUE_NOT_FOUND');
|
||||
assert.deepEqual(writes(stub), []);
|
||||
});
|
||||
|
||||
test('--index 不是正整數時擋在打 Gitea 之前', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await runScript('timer.js', ['--repo', REPO, '--index', '0'], {
|
||||
env: envFor(stub),
|
||||
});
|
||||
|
||||
assert.equal(json.error.code, 'BAD_INDEX');
|
||||
assert.equal(stub.requests.length, 0);
|
||||
});
|
||||
|
||||
test('--dry-run 印出將發出的寫入,但一個字都不寫進去', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run(['--dry-run'], stub);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.dryRun, true);
|
||||
assert.deepEqual(
|
||||
json.data.requests.map((r) => `${r.method} ${r.path}`),
|
||||
[`POST /repos/${REPO}/issues/${INDEX}/stopwatch/start`],
|
||||
);
|
||||
assert.deepEqual(writes(stub), [], '預覽不得真的寫入');
|
||||
});
|
||||
|
||||
test('--dry-run 會先讀現況:已經在計時時預覽出來就是什麼都不做', async (t) => {
|
||||
const stub = await withStub(t, {}, { stopwatches: [stopwatchOn(INDEX)] });
|
||||
|
||||
const { json } = await run(['--dry-run'], stub);
|
||||
|
||||
assert.deepEqual(json.data.requests, [], '手寫一份固定的清單會跟實作走鐘');
|
||||
assert.equal(json.data.已在計時, true);
|
||||
});
|
||||
@@ -1,138 +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('架構圖依性質三選一,並釘住節點與字數上限', () => {
|
||||
for (const kind of ['sequenceDiagram', 'flowchart', 'stateDiagram-v2']) {
|
||||
assert.match(prompt, new RegExp(kind));
|
||||
}
|
||||
const limits = prompt.slice(prompt.indexOf('## 架構圖的限制'));
|
||||
assert.match(limits, /12/);
|
||||
assert.match(limits, /8\s*字/);
|
||||
assert.match(limits, /拆成多張圖|不畫/);
|
||||
});
|
||||
|
||||
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, /只在超過上限拆不開、或畫了不會比文字更清楚時/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 需求議題底下的工作包清單。
|
||||
*
|
||||
* 這一支存在的理由只有一個:使用者手上有一顆需求議題編號,要挑出底下的某一顆工作包。
|
||||
* 所以測試集中在「挑得對不對」:認的是不是工作包抽取那一套關聯判準、PR 會不會混進來、
|
||||
* 清單讀不讀得完。半份清單最危險——使用者會從缺了幾顆的清單裡挑,而且看不出缺了誰。
|
||||
*/
|
||||
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 REQUIREMENT = 7;
|
||||
|
||||
/** 一顆工作包的 body:判準只在「關聯」段落那一行 */
|
||||
function wpBody(requirement) {
|
||||
return `## 這個工作包在做什麼
|
||||
|
||||
一句話。
|
||||
|
||||
## 待辦
|
||||
|
||||
- [ ] 做一件事
|
||||
|
||||
## 關聯
|
||||
|
||||
需求議題:#${requirement}
|
||||
估算人天:2
|
||||
`;
|
||||
}
|
||||
|
||||
function issue(index, { title = `工作包 ${index}`, body = wpBody(REQUIREMENT), ...rest } = {}) {
|
||||
return {
|
||||
number: index,
|
||||
title,
|
||||
body,
|
||||
state: 'open',
|
||||
html_url: `https://gitea.example/${REPO}/issues/${index}`,
|
||||
assignee: null,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
/** 假 Gitea:議題清單逐頁回,其餘走 healthy 預設 */
|
||||
function routes(issues, overrides = {}) {
|
||||
return healthyRoutes(REPO, {
|
||||
[`GET /api/v1/repos/${REPO}/issues`]: (req) => {
|
||||
const page = Number(req.query.page ?? 1);
|
||||
const limit = Number(req.query.limit ?? 50);
|
||||
return { status: 200, body: issues.slice((page - 1) * limit, page * limit) };
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
async function run(t, issues) {
|
||||
const stub = await withStubGitea(t, routes(issues));
|
||||
const result = await runScript(
|
||||
'wp-list.js',
|
||||
['--repo', REPO, '--requirement', String(REQUIREMENT)],
|
||||
{ env: envFor(stub) },
|
||||
);
|
||||
return { stub, ...result };
|
||||
}
|
||||
|
||||
// ── 挑出哪幾顆 ─────────────────────────────────────────────────────
|
||||
|
||||
test('只收關聯指回這顆需求議題的工作包', async (t) => {
|
||||
const { code, json } = await run(t, [
|
||||
issue(9),
|
||||
issue(10, { body: wpBody(99) }),
|
||||
issue(11),
|
||||
]);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.deepEqual(
|
||||
json.data.工作包.map((wp) => wp.index),
|
||||
[9, 11],
|
||||
);
|
||||
assert.equal(json.data.數量, 2);
|
||||
assert.equal(json.data.需求議題, REQUIREMENT);
|
||||
});
|
||||
|
||||
test('沒有關聯段落的議題不算工作包', async (t) => {
|
||||
const { json } = await run(t, [issue(9), issue(12, { body: '## 總覽\n\n這是一顆需求議題。\n' })]);
|
||||
|
||||
assert.deepEqual(json.data.工作包.map((wp) => wp.index), [9]);
|
||||
});
|
||||
|
||||
test('PR 不進清單,即使它的描述也寫了需求議題', async (t) => {
|
||||
// pr-create 產出的 PR 描述本來就有「需求議題:#N」那一行,光看 body 會把 PR 當成工作包
|
||||
const { json } = await run(t, [
|
||||
issue(9),
|
||||
issue(30, { title: 'feat: 某某', pull_request: { merged: false } }),
|
||||
]);
|
||||
|
||||
assert.deepEqual(json.data.工作包.map((wp) => wp.index), [9]);
|
||||
});
|
||||
|
||||
test('已關閉的工作包照樣列出,並帶上狀態與領取人', async (t) => {
|
||||
const { json } = await run(t, [
|
||||
issue(9, { state: 'closed', assignee: { login: 'jeffery' } }),
|
||||
]);
|
||||
|
||||
assert.deepEqual(json.data.工作包, [
|
||||
{
|
||||
index: 9,
|
||||
title: '工作包 9',
|
||||
url: `https://gitea.example/${REPO}/issues/9`,
|
||||
state: 'closed',
|
||||
assignee: 'jeffery',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('依編號由小到大排,清單的順序不隨 Gitea 回傳順序飄動', async (t) => {
|
||||
const { json } = await run(t, [issue(14), issue(9), issue(11)]);
|
||||
|
||||
assert.deepEqual(json.data.工作包.map((wp) => wp.index), [9, 11, 14]);
|
||||
});
|
||||
|
||||
test('一顆都沒有時回空清單而不是報錯', async (t) => {
|
||||
const { code, json } = await run(t, [issue(10, { body: wpBody(99) })]);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.deepEqual(json.data.工作包, []);
|
||||
assert.equal(json.data.數量, 0);
|
||||
});
|
||||
|
||||
test('逐頁讀完,不只讀第一頁', async (t) => {
|
||||
const many = Array.from({ length: 60 }, (_, i) => issue(i + 1));
|
||||
|
||||
const { json, stub } = await run(t, many);
|
||||
|
||||
assert.equal(json.data.數量, 60);
|
||||
const listings = stub.requests.filter((r) => r.path === `/api/v1/repos/${REPO}/issues`);
|
||||
assert.ok(listings.length >= 2, `要翻到第二頁,實際只發了 ${listings.length} 次`);
|
||||
});
|
||||
|
||||
test('讀的是全部狀態的議題,不只 open', async (t) => {
|
||||
const { stub } = await run(t, [issue(9)]);
|
||||
|
||||
const listing = stub.requests.find((r) => r.path === `/api/v1/repos/${REPO}/issues`);
|
||||
assert.equal(listing.query.state, 'all');
|
||||
});
|
||||
|
||||
// ── 契約 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('--dry-run 列出將發出的請求,且完全不碰 Gitea', async (t) => {
|
||||
const stub = await withStubGitea(t, routes([issue(9)]));
|
||||
|
||||
const { code, json } = await runScript(
|
||||
'wp-list.js',
|
||||
['--repo', REPO, '--requirement', String(REQUIREMENT), '--dry-run'],
|
||||
{ env: envFor(stub) },
|
||||
);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.dryRun, true);
|
||||
assert.deepEqual(json.data.requests, [
|
||||
{ method: 'GET', path: `/repos/${REPO}/issues` },
|
||||
]);
|
||||
assert.equal(stub.requests.length, 0);
|
||||
});
|
||||
|
||||
test('缺 --requirement 時指名缺的是哪一個', async (t) => {
|
||||
const stub = await withStubGitea(t, routes([]));
|
||||
|
||||
const { json } = await runScript('wp-list.js', ['--repo', REPO], { env: envFor(stub) });
|
||||
|
||||
assert.equal(json.error.code, 'MISSING_FLAG');
|
||||
assert.match(json.error.message, /--requirement/);
|
||||
});
|
||||
|
||||
test('--requirement 不是正整數時擋下', async (t) => {
|
||||
const stub = await withStubGitea(t, routes([]));
|
||||
|
||||
const { json } = await runScript('wp-list.js', ['--repo', REPO, '--requirement', 'abc'], {
|
||||
env: envFor(stub),
|
||||
});
|
||||
|
||||
assert.equal(json.error.code, 'BAD_INDEX');
|
||||
});
|
||||
|
||||
test('議題多到翻不完時報錯,不回半份清單', async (t) => {
|
||||
// 每一頁都回滿頁,永遠不會結束:翻到上限就要大聲說讀不完
|
||||
const stub = await withStubGitea(
|
||||
t,
|
||||
healthyRoutes(REPO, {
|
||||
[`GET /api/v1/repos/${REPO}/issues`]: (req) => ({
|
||||
status: 200,
|
||||
body: Array.from({ length: Number(req.query.limit ?? 50) }, (_, i) => issue(i + 1)),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { code, json } = await runScript(
|
||||
'wp-list.js',
|
||||
['--repo', REPO, '--requirement', String(REQUIREMENT)],
|
||||
{ env: envFor(stub) },
|
||||
);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'WORK_PACKAGE_LIMIT');
|
||||
});
|
||||
@@ -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 與看板/);
|
||||
});
|
||||
Reference in New Issue
Block a user