Compare commits
110
Commits
dbce8dbd3c
...
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 | ||
|
|
c2ca7fbf07 | ||
|
|
9ed719a4d7 | ||
|
|
1c678d311a | ||
|
|
66064a751e | ||
|
|
0dd8d7b7bb | ||
|
|
2562555058 | ||
|
|
7196385c5e | ||
|
|
643293b21c | ||
|
|
3a435bf2d9 | ||
|
|
ceb723c63e | ||
|
|
f85fabb758 | ||
|
|
3ad08be14f | ||
|
|
16f93f6e8b | ||
|
|
5ef25d3ee9 | ||
|
|
c1ab712afd | ||
|
|
3b7e3bdfc7 | ||
|
|
051fe0ded4 | ||
|
|
b263915afc | ||
|
|
ee474d0439 | ||
|
|
695694b3fd | ||
|
|
d0e7e569a3 | ||
|
|
617a3eef72 | ||
|
|
32edd65962 | ||
|
|
d331bef1ed | ||
|
|
9e049580bb | ||
|
|
43764f457e | ||
|
|
86bdfd9007 | ||
|
|
a1bbd78cb4 | ||
|
|
6453c78fd1 | ||
|
|
8d9bcd65e3 | ||
|
|
759b53adeb | ||
|
|
de1fd08efa | ||
|
|
cc29b5bc21 | ||
|
|
3084c91041 | ||
|
|
d3ce364727 | ||
|
|
0154cf59d4 | ||
|
|
4583a5f210 | ||
|
|
74b4ca130e | ||
|
|
5b740c236b | ||
|
|
03c368b6de | ||
|
|
997d4ec280 | ||
|
|
013947630f | ||
|
|
da5b670f29 | ||
|
|
1c6e7f85bb | ||
|
|
95e6026950 | ||
|
|
fbc80f286f | ||
|
|
30297cc49a | ||
|
|
8a6145ea14 | ||
|
|
f99adab245 | ||
|
|
513dad7f6a | ||
|
|
0b728ca270 | ||
|
|
958b1f85e8 | ||
|
|
b5214ed0f1 | ||
|
|
d6b44e0ba8 | ||
|
|
bb886457fd | ||
|
|
1c6fb8aaad | ||
|
|
8ea6a2a8b3 | ||
|
|
a45e981c95 | ||
|
|
6e0fa92e73 | ||
|
|
586b746b55 | ||
|
|
9582553c41 |
@@ -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"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
本 repo 以 npm 佈署:`npm i -g <git url>` 裝出 `tea-sdlc` 指令,`tea-sdlc install` 產生各平台轉接檔。
|
||||
轉接檔裡沒有路徑,只有一句 `tea-sdlc prompt --name <指令名>`,正本在哪由入口自己回推。
|
||||
|
||||
> 六個流程正本尚未到齊,安裝器只佈署 `prompts/` 裡已經存在的指令。進度見
|
||||
> 六個流程正本都到齊了;安裝器佈署的就是 `prompts/` 裡的那六份。進度見
|
||||
> [議題 #1](https://gitea.jsc.idv.tw/plugins/tea-sdlc/issues/1) 底下的工作包。
|
||||
|
||||
## 模組邊界
|
||||
@@ -17,17 +17,26 @@
|
||||
| 目錄 | 職責 | 邊界 |
|
||||
| --- | --- | --- |
|
||||
| `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` 或任何設定檔。
|
||||
唯一的例外是 git 自己的內部中繼資料——`git worktree add` 一定會在目標 repo 的
|
||||
`.git/worktrees/` 底下寫東西,那是 git 的機制,無法避免,也不是專案的內容檔。
|
||||
- **工作樹集中在家目錄**:每顆工作包的工作樹開在 `~/.tea-sdlc/worktrees/{hash}`,
|
||||
路徑由 `owner/repo/分支名` 純函式推導(`scripts/lib.js` 的 `worktreePath`),
|
||||
不查表也不寫狀態檔。不開在目標專案裡(會出現在它的 `git status`),
|
||||
也不開在它的兄弟目錄(那個目錄結構屬於使用者)。
|
||||
- **不自動觸發**:所有指令僅由使用者明確叫用;skill/command 的 `description` 統一以「僅由 /sdlc-xxx 指令叫用。」起頭。
|
||||
|
||||
@@ -1,15 +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` 只會佈署 `prompts/` 裡已經存在的指令。
|
||||
> 完整需求見[議題 #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) 底下的工作包。
|
||||
|
||||
---
|
||||
|
||||
@@ -20,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` |
|
||||
|
||||
---
|
||||
|
||||
@@ -34,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
|
||||
@@ -42,6 +43,7 @@ tea-sdlc/
|
||||
├── plugin.json # Antigravity 的 plugin manifest
|
||||
├── package.json # npm 打包與測試入口,無任何相依套件
|
||||
├── AGENTS.md # 給 AI 助理的模組邊界與慣例
|
||||
├── docs/adr/ # 已接受的架構決策紀錄
|
||||
└── README.md
|
||||
```
|
||||
|
||||
@@ -55,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 權限 |
|
||||
|
||||
---
|
||||
@@ -95,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 |
|
||||
@@ -103,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,也不連網。
|
||||
|
||||
---
|
||||
|
||||
@@ -119,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
-208
@@ -3,239 +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`
|
||||
把它們整併回描述,再回來做分析。使用者堅持要繼續就繼續,但要記下這件事,
|
||||
並在共識摘要裡註明「分析基於未整併留言前的描述」。
|
||||
只產出可核對的疑點清單,不替使用者做決策;你的環境若能把工作交給子代理,就交出去,只把清單帶回來;不能就自己做。
|
||||
|
||||
### 2. 對四份清單列出疑點
|
||||
### 2. 逐題確認可行性共識
|
||||
|
||||
依序讀這四份規則正本,逐條對照議題內容:
|
||||
能從程式碼查證的事項自行查證;只有需要使用者決策的事項才提問。架構、邏輯、資料、時程四類依序完成,每次只問一題,每題提供建議與理由,以及手動輸入的方式。最後輸出共識摘要、變更假設、未決事項與人天估算;摘要只印終端,不寫入 Gitea。
|
||||
|
||||
1. `references/feasibility-architecture.md` — 架構:放錯 repo、循環相依、穿越邊界。
|
||||
2. `references/feasibility-logic.md` — 邏輯:既有功能是不是已經做過同一件事。
|
||||
3. `references/feasibility-data.md` — 資料:schema 變更、遷移、交易邊界。
|
||||
4. `references/feasibility-schedule.md` — 時程:相依鏈最長路徑、未知數最大的一項。
|
||||
### 交付文件判斷
|
||||
|
||||
每一條檢查若在議題裡找不到答案,就轉成一個問題。**能在程式碼裡查證的就自己去查,
|
||||
不要拿去問使用者**——把問題留給只有人能回答的事。
|
||||
使用者確認共識摘要後,讀取 `references/delivery-types.md`,列出這次需求需要交付或驗收的文件,並依序逐一詢問:
|
||||
|
||||
### 3. 逐題問到共識
|
||||
1. **需求描述概要**
|
||||
2. **WBS(工作分解結構)**
|
||||
3. **流程圖**
|
||||
4. **甘特圖**
|
||||
5. **PERT 圖**
|
||||
6. **關鍵路徑圖**
|
||||
7. **API 契約文件**
|
||||
|
||||
**一次問一題。** 問完等使用者回答,再問下一題,讓他能看著前一題的答案回答下一題。
|
||||
不要一次丟出五個問題,也不要把多個問題包成一題的多個選項。
|
||||
每一種文件都要先展示必要內容骨架,再給出是否需要的建議與理由,最後讓使用者確認、拒絕或手動調整;不得把七種文件合併成一次確認。確認結果要保留文件順序、必要內容、產出位置與 ELI5 變體規則。API 契約文件只能交付預覽或使用者確認的位置,禁止寫入目標專案 repo。
|
||||
|
||||
順序固定為**架構 → 邏輯 → 資料 → 時程**,前一類的問題全部清空才進入下一類。
|
||||
前面的答案常常會讓後面的問題消失或改寫;每問完一題,重新檢視剩下的問題還成不成立。
|
||||
使用者未確認或拒絕任何一項時,立即停止,不建立工作包、不排程、不建立相依、不寫入 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
-96
@@ -1,117 +1,43 @@
|
||||
name: sdlc-feat
|
||||
description: 僅由 /sdlc-feat 指令叫用。領取一顆工作包、起錶、備妥分支,逐項實作並勾選待辦,最後分批提交並開立 PR。
|
||||
description: 僅由 /sdlc-feat 指令叫用。領取工作包、開分支、逐項實作並開 PR。
|
||||
|
||||
# sdlc-feat
|
||||
|
||||
拿一顆工作包,從領取到開出 PR。
|
||||
輸入一個工作包議題編號。先用 `scripts/wp-extract.js` 抽取;若抽取結果有 `未處理留言數`,直接走 `/sdlc-sync` 的流程,完成後自動接回這裡;不要要求使用者重打指令。接回前重新抽取一次拿到更新後的描述。
|
||||
|
||||
第一段**領取與開工準備**:把工作包安全地認領下來,開始計時,備妥開工的分支。
|
||||
這一段不改任何一行程式碼——它只負責讓後面的實作有個乾淨的起點。
|
||||
依工作包 `repos` 逐一準備 worktree;不在主工作區切換分支。逐項完成待辦並立即勾選對應驗收。交付優先項目位於待辦第一項時先完成;仍須遵守工作包的依賴與範圍邊界。
|
||||
|
||||
這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。
|
||||
## 交付文件待辦分流
|
||||
|
||||
## 輸入
|
||||
先把每一項待辦判定為程式碼待辦或交付文件待辦;兩者不可共用無差別的實作流程。交付文件待辦是需求描述概要、WBS、流程圖、甘特圖、PERT 圖、關鍵路徑圖或 API 契約文件等文件產出,判定依 `references/delivery-types.md` 的內容與產出位置。
|
||||
|
||||
一個工作包議題編號。
|
||||
### 交付前逐一確認
|
||||
|
||||
## 第一段:領取與開工準備
|
||||
每一份交付文件都要分開處理。產出前先列出該類型的必要內容骨架、已知來源、未決事項、建議與理由,再一次只問一題確認內容是否齊全;未確認的文件不可標成已交付。能從需求、工作包、相依或排程重新推導的內容,保留來源與推導規則,不自行編造決策。
|
||||
|
||||
### 1. 讀工作包
|
||||
### 預覽能力分流
|
||||
|
||||
```
|
||||
node scripts/wp-extract.js --repo <owner/name> --index <編號>
|
||||
```
|
||||
以能力描述檢查目前環境是否能把該文件即時發佈成可開啟、可分享的預覽頁,並說明判定依據;不要靠固定清單或猜測環境。確認具備能力後直接產出預覽,回報實際位置與內容摘要。無法證明具備能力、預覽不可開啟或不可分享時,停止產出並一次只問一題,由 agent 依文件、來源與限制提出建議及理由,同時保留手動輸入,不替使用者決定交付方式。
|
||||
|
||||
拿到的是結構化欄位:待辦與它自己的驗收、範圍邊界、介面契約、相依、repo 列表。
|
||||
不必再讀整份議題全文。
|
||||
API 契約文件只能交付在預覽位置或使用者確認的位置;不得寫入目標專案 repo,也不得藉由缺少預覽能力而改寫目標專案的設定檔或文件。預覽失效時回報實際限制,不捏造網址。
|
||||
|
||||
**先看 `未處理留言數`。** 只要不是 0,就代表議題描述可能是過期的——留言裡有決策還沒被
|
||||
整併回描述。這時**先停下來**告訴使用者有幾則未整併的留言,建議先執行 `/sdlc-sync`
|
||||
把它們整併回描述,再回來實作。使用者堅持要繼續就繼續,但要記下這件事,
|
||||
並在最後的 PR 描述裡註明「實作基於未整併留言前的描述」。
|
||||
### ELI5 變體
|
||||
|
||||
**再看 `相依.depends`。** 裡面還有沒關閉的議題,代表這顆的前置還沒做完。照樣先說出來,
|
||||
讓使用者決定要不要現在做。
|
||||
使用者要求 ELI5 時,仍保留原文件的範圍、順序、相依、例外與驗收意義;把術語換成日常說法並補必要的短解釋,不刪除技術限制。圖表要重新繪製成容易閱讀的圖片式視覺,不把 Mermaid 原碼當成交付物,也不只用一個看似精確的日期隱藏 O/M/P 不確定性。
|
||||
### 把議題標題翻成英文〔可委派〕
|
||||
|
||||
### 2. 領取工作包
|
||||
把工作包議題標題轉成不超過 40 字元的英文 kebab slug,保留原意且不捏造新範圍;這一步只產出可驗證的 slug,你的環境若能把工作交給子代理,就交出去,只把 slug 帶回來;不能就自己做。若有兩個同樣合理的翻法,交回候選與差異,由主流程詢問使用者。
|
||||
|
||||
先試跑,看清楚會做什麼:
|
||||
## 實作與交付
|
||||
### 分批提交方案〔可委派〕
|
||||
|
||||
```
|
||||
node scripts/claim.js --repo <owner/name> --index <編號> --dry-run
|
||||
```
|
||||
依檔案類型與變更性質計算 commit 分類、順序與每批檔案;只回傳可核對的提交方案。實際執行 `scripts/commit-split.js`、處理失敗與確認 git 歷史不委派,由主流程自己完成。
|
||||
|
||||
確認無誤後拿掉旗標再跑一次。放行時它會設 assignee、貼「進行中」標籤、起錶——三件事
|
||||
一起構成領取鎖,錶則是工時的來源。
|
||||
|
||||
領取鎖有四種狀態,三種擋、一種放行。被擋下來時**不要繞過去**,照著錯誤碼告訴使用者
|
||||
發生什麼事、下一步是什麼:
|
||||
|
||||
| 狀態 | 錯誤碼 | 下一步 |
|
||||
| --- | --- | --- |
|
||||
| 別人已經認領這顆 | `CLAIMED_BY_OTHER` | 改領別顆,或先跟對方確認 |
|
||||
| 你的錶已經跑在這顆上 | `STOPWATCH_ON_THIS_ISSUE` | 這顆你正在做;要重新計時請先手動停錶 |
|
||||
| 你的錶跑在別的議題上 | `STOPWATCH_ON_OTHER_ISSUE` | 多半是忘了停上一顆;先去停掉再回來 |
|
||||
| 沒有鎖 | —— | 放行。自己已認領但沒起錶也算沒有鎖,那正是中斷後重跑的情形 |
|
||||
|
||||
碼錶一律由使用者自己停。哪一段時間該記在哪顆議題上只有他知道,代勞會把工時記錯地方。
|
||||
|
||||
鎖以外還有一個前置條件:repo 上要有「進行中」標籤。缺了會得到 `LABEL_NOT_FOUND`,
|
||||
請使用者自己去建立——**不要自己建**,標籤體系不該在多個 repo 之間長出雜草。
|
||||
|
||||
### 3. 問來源分支
|
||||
|
||||
**一次問一題。** 新分支要從哪裡長出來,只有使用者知道,不要替他決定。
|
||||
給兩個選項,並附上你判斷的理由:
|
||||
|
||||
- **建議** — 你的答案。多數情況是開發分支(`master`/`main`/`develop`);
|
||||
但若這顆工作包明顯是某個既有功能分支的一部分,就建議那一支,並說明為什麼。
|
||||
- **手動輸入** — 讓使用者自己填分支名。
|
||||
|
||||
### 4. 把議題標題翻成英文
|
||||
|
||||
分支名的中段要用英文,中文會讓 CI 與 URL 出問題。把工作包議題的標題翻成
|
||||
**小寫英文 kebab、40 字元以內**,例如「建立工作包的抽取契約」→ `wp-extract-contract`。
|
||||
|
||||
翻譯要保留原意而不是逐字直譯,寧可用一個更短的說法,也不要把長句截斷成看不懂的字串。
|
||||
|
||||
### 5. 備妥分支
|
||||
|
||||
分支開在**工作包的 `repos` 列出的那些 repo** 上,不是開在本 plugin 的目錄裡。
|
||||
`repos` 只有一顆就用那一顆;**有多顆時逐一確認**要在哪幾個開分支,
|
||||
再對每一個各跑一次 `branch-prep`,分支名在每個 repo 都相同。
|
||||
|
||||
```
|
||||
node scripts/branch-prep.js --path <目標專案路徑> --source <來源分支> \
|
||||
--slug <英文-kebab> [--type feat] --dry-run
|
||||
```
|
||||
|
||||
`--type` 只在來源是開發分支時要給(`feat`/`fix`/`chore`…);從功能分支長出時,
|
||||
類型與需求描述沿用來源,不必也不能再指定。
|
||||
|
||||
試跑會印出將執行的 git 指令與算出來的分支名。確認無誤後拿掉旗標再跑一次。
|
||||
|
||||
它保證三件事,都是為了不弄丟別人的東西:工作區不乾淨時**先擋下來**,免得把不相干的
|
||||
改動帶進這顆工作包的分支;來源分支在遠端已存在時是 **pull 而不是重建**;目標分支已經
|
||||
存在時是**接上去而不是蓋掉**。
|
||||
|
||||
工作區不乾淨(`DIRTY_WORKTREE`)時,把 git 回報的檔案念給使用者聽,讓他決定要提交、
|
||||
`git stash` 還是丟掉——**不要自己選**。
|
||||
|
||||
### 6. 回報
|
||||
|
||||
印出一份開工前的現況,不寫回議題:
|
||||
|
||||
- 工作包標題與網址、這一顆有幾項待辦
|
||||
- 認領結果(是否本來就是自己的)、碼錶已起
|
||||
- 來源分支、新分支名、分支是新建還是接上既有
|
||||
- 未處理留言數與未關閉的先決議題(若有)
|
||||
完成程式碼待辦後照既有測試與驗證慣例;文件待辦則依上述分流交付。完成後依既有 commit 分類規則提交,先用 `scripts/pr-create.js --dry-run` 檢查,再實跑開 PR。回報工作包、分支、worktree、完成待辦、commit 與 PR。
|
||||
|
||||
## 邊界
|
||||
|
||||
- 第一段**不改任何一行程式碼**、不勾待辦、不提交、不開 PR——那些是後面幾段的事。
|
||||
- 不自行建立標籤。缺「進行中」標籤時中止並請使用者建立。
|
||||
- 不代替使用者停錶,也不在被鎖擋下時繞過去。
|
||||
- 不替使用者決定來源分支。
|
||||
- **不寫任何本機狀態檔。** 進度完全由 Gitea 上的 assignee、標籤、碼錶與 git 本身推導,
|
||||
換一台機器或換一個 agent 都要能直接接手。
|
||||
- 不修改工作包範圍外的檔案。
|
||||
- 不切換主工作區分支。
|
||||
- 不產生固定選項清單,不把平台工具名或呼叫語法寫入流程正本。
|
||||
- 不操作碼錶、不補登工時、不產生耗時統計。
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
name: sdlc-fix
|
||||
description: 僅由 /sdlc-fix 指令叫用。收 PR 或議題編號:PR 就定位工作樹、逐條處理三類留言並回覆;議題則交棒給 /sdlc-feat。
|
||||
|
||||
# sdlc-fix
|
||||
|
||||
reviewer 留完意見,跑這一段,意見被逐條處理並回覆,不漏掉任何一則。
|
||||
|
||||
**意見不一定發在 PR 上。** 常常是發在工作包議題或需求議題的留言裡:使用者手上只有一個
|
||||
議題編號,看得到有人說「這裡要改」。所以輸入收得下三種東西,但只有 PR 在這裡處理完——
|
||||
議題交棒給 `/sdlc-feat`,不在這裡重做一遍它的流程。
|
||||
|
||||
這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。
|
||||
|
||||
## 輸入
|
||||
|
||||
一個 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 現況,並定位工作樹
|
||||
|
||||
先問一次現況,再決定要不要動手:
|
||||
|
||||
```
|
||||
node scripts/pr-watch.js --repo <owner/name> --index <PR 編號> --dry-run
|
||||
```
|
||||
|
||||
**這裡要帶 `--dry-run`**:`pr-watch` 在 PR 已終止時會順手清掉工作樹,而這一步只是要
|
||||
知道現況——清不清理是使用者的決定,不該由「我想看一下留言」這個動作順便做掉。
|
||||
|
||||
**`terminal` 為 `true`(PR 已合併或已關閉)時就停下來,不要繼續處理留言。** 那顆工作包
|
||||
已經結束,在一棵該被清掉的工作樹上改東西是白做工,而且那些改動不會進到任何 PR 裡。
|
||||
把 `suggestedAction` 的意思講給使用者聽,讓他決定下一步:
|
||||
|
||||
| 值 | 意思 |
|
||||
| --- | --- |
|
||||
| `nothing-to-do` | 沒事了;工作樹不在或已經清掉 |
|
||||
| `cleanup` | 工作樹還在,可以用 `worktree-remove` 清掉 |
|
||||
| `blocked-dirty` | 那棵工作樹裡還有沒提交的東西,要他自己處理 |
|
||||
|
||||
PR 還開著就定位工作樹。輸入是 `pr-watch` 給的 `branch`——路徑由「哪顆工作包」推導,
|
||||
不必也不該由使用者自己去記那串雜湊目錄名:
|
||||
|
||||
```
|
||||
node scripts/worktree-ensure.js --repo <owner/name> --path <目標專案路徑> \
|
||||
--branch <pr-watch 給的 branch> --dry-run
|
||||
```
|
||||
|
||||
`--path` 是目標專案在本機的位置(工作包 `repos` 列的那一顆),不給就用當前目錄——
|
||||
而當前目錄多半不是它,這正是這一步要解決的問題。
|
||||
|
||||
試跑會印出推導出的路徑與將執行的 git 指令;確認無誤後拿掉旗標再跑一次。
|
||||
|
||||
**推導出的路徑不存在時它會重建,那是常態不是例外**:進度完全不寫在本機,換一台機器
|
||||
或換一個 agent 接手時工作樹本來就不在,重建的成本就是一次 `git worktree add`。
|
||||
重建走的是與開工時同一套建立方式,分支上已經有的進度會被接上,不是長一棵空的。
|
||||
|
||||
兩種會被擋下來的情況照實說,不要繞過去:`BRANCH_NOT_FOUND` 是那一支分支在本機與遠端
|
||||
都不見了(多半是 PR 已經合併而分支被刪,回頭確認 PR 狀態);`WORKTREE_PATH_TAKEN` 是
|
||||
推導出的路徑上有別的東西,請使用者自己確認後移除——**不要自己刪**。
|
||||
|
||||
**後面每一步都在那棵工作樹裡做**,不要回到主工作區:它可能停在別的分支上,在那裡改
|
||||
會把改動落到別顆工作包的分支去。
|
||||
|
||||
## 4. 讀留言
|
||||
|
||||
```
|
||||
node scripts/pr-comments.js --repo <owner/name> --index <PR 編號>
|
||||
```
|
||||
|
||||
第 1 步判定型別時讀的就是這一份,**手上那一份還在就直接用,不必再讀一次**。
|
||||
|
||||
三類留言一次讀齊:**一般留言**、**review 總評**、**行內留言**。每一則都帶 `id`、`作者`、
|
||||
`內容`、`已處理`,行內的還帶 `檔案`/`行`/`diff`——那段 diff 是判斷「他在說哪裡」的依據,
|
||||
不要略過不看。
|
||||
|
||||
**先看 `未處理數`。** 它是這一輪要處理的量;`已處理` 為 `true` 的那些是前一輪做過的,
|
||||
跳過不再處理。
|
||||
|
||||
**三類都標記得了,機制不同**:一般留言與 review 總評用 `+1` reaction,行內留言用
|
||||
resolve。`已處理` 認的是**自己打的** `+1`——reviewer 對留言按讚是「我同意」,不是
|
||||
「這則處理過了」。
|
||||
|
||||
偶爾會遇到 `可標記` 是 `false` 的總評(在 timeline 上對不到它在 issue comment 表裡的
|
||||
那一份)。那一則回覆照發,但沒有記號留得下來,**要在修正摘要裡單獨點出來**。
|
||||
|
||||
## 5. 分類:必改還是建議
|
||||
|
||||
逐則判斷,**reviewer 不必逐則說明**。判斷依據是內容本身:
|
||||
|
||||
- **必改** — 指出了錯誤、遺漏、會出事的寫法,或明確要求改動。
|
||||
- **建議** — 提出另一種做法、風格偏好、「之後可以考慮」。
|
||||
|
||||
分類結果**先呈現給使用者**再動手:列出每一則的「類型/作者/一句話摘要/你的分類」。
|
||||
分類錯的代價不對稱——把必改當成建議會漏掉真的問題,所以拿不準時歸到必改那一邊,
|
||||
並在下一步問清楚。
|
||||
|
||||
## 6. 不確定就問
|
||||
|
||||
**一次問一題。** 下列情況不要自作主張:
|
||||
|
||||
- 分不出必改還是建議。
|
||||
- 知道要改,但有兩種以上做法,而選擇會影響別處。
|
||||
- 留言本身看不懂,或它指的位置與現在的程式碼對不上(PR 之後又推了新 commit 是常見原因)。
|
||||
|
||||
每一題給兩個選項,並附上你判斷的理由:
|
||||
|
||||
- **建議** — 你的答案,寫「為什麼是這個」。
|
||||
- **手動輸入** — 讓使用者自己說。
|
||||
|
||||
**不確定卻硬改,比多問一題貴得多。** 改壞的地方 reviewer 下一輪才會看到。
|
||||
|
||||
## 7. 逐則處理並回覆
|
||||
|
||||
一則一則來:先改,改完立刻回覆那一則,再處理下一則。**不要全部改完才一起回**——
|
||||
中途斷掉的話,沒有人知道哪幾則已經處理過。
|
||||
|
||||
```
|
||||
node scripts/pr-reply.js --repo <owner/name> --index <PR 編號> \
|
||||
--comment <留言 id> --kind inline|general|review --body '<回覆>' --dry-run
|
||||
```
|
||||
|
||||
`--kind` 對應 `pr-comments` 給的 `類型`:`行內` → `inline`、`一般` → `general`、
|
||||
`總評` → `review`。**三類留言的 id 各自獨立**,`--kind` 給錯會找不到那一則。
|
||||
|
||||
腳本自己會去查行內留言的位置(含它在新檔還是被刪掉的那一側)與它所屬的 commit,
|
||||
把回覆放回同一串;也會在回覆成功之後才標記(行內用 resolve,一般與總評用 `+1`)。
|
||||
回覆失敗就不標記——沒回卻標記等於謊稱處理過。
|
||||
|
||||
**留言指向的程式碼已經被改掉時**,回覆可能會被 Gitea 拒絕(位置對不上)。那時不要硬試,
|
||||
把那一則列進摘要的「無法處理」,讓使用者自己去 PR 上回。
|
||||
|
||||
**回覆要說出做了什麼**,不是「已修正」。reviewer 看回覆就要知道改法對不對,
|
||||
不必自己去翻 diff。決定不改的也要回,並說明理由——建議類的留言常常合理地不採納,
|
||||
但沉默會讓 reviewer 以為被忽略了。
|
||||
|
||||
## 8. 修正摘要
|
||||
|
||||
全部處理完後印一則摘要,讓 reviewer 不必逐串點開:
|
||||
|
||||
- **必改幾則、建議幾則**,各自處理了幾則、不改幾則。
|
||||
- **逐則一行**:作者/一句話原意/你的處置。
|
||||
- **沒有留下記號的那幾則**(`可標記` 為 `false`,或腳本回報 `已標記: false`)要單獨
|
||||
列出來,否則 reviewer 掃 reaction 與 resolve 時會以為它們被跳過了。
|
||||
- **問過使用者的題目與答案**。
|
||||
- 有沒有留言因為指向的程式碼已經變了而無法處理。
|
||||
|
||||
摘要只印在終端,**不自動張貼到 PR 上**。要不要貼由使用者決定。
|
||||
|
||||
## 邊界
|
||||
|
||||
- 不改與留言無關的程式碼。順手看到的問題記下來說出來,不要摸進這一輪。
|
||||
- 不自行判斷不確定的事——寧可多問一題。
|
||||
- 不跳過任何一則留言。決定不改的也要回覆並說明理由。
|
||||
- 不在回覆沒成功時標記已處理。
|
||||
- **不自動張貼修正摘要**,也不自動關閉或合併 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。
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
name: sdlc-sync
|
||||
description: 僅由 /sdlc-sync 指令叫用。把議題留言裡的決策整併回議題描述,並標記已整併的留言。
|
||||
|
||||
# sdlc-sync
|
||||
|
||||
新加入的人不必爬完整串留言,就能從議題描述知道現況。
|
||||
|
||||
這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。
|
||||
|
||||
## 輸入
|
||||
|
||||
一個議題編號。可能是需求議題,也可能是工作包議題。
|
||||
|
||||
## 1. 讀議題與留言
|
||||
|
||||
需求議題用 `issue-extract`,工作包議題用 `wp-extract`:
|
||||
|
||||
```
|
||||
node scripts/issue-extract.js --repo <owner/name> --index <編號>
|
||||
```
|
||||
|
||||
兩支都會給 `未處理留言數`。**那是這一輪要看的量**;已經標記過的留言不再處理。
|
||||
|
||||
接著讀留言本身。抽取契約只給數字不給內容,所以留言要另外拿:
|
||||
|
||||
```
|
||||
node scripts/pr-comments.js --repo <owner/name> --index <編號>
|
||||
```
|
||||
|
||||
議題與 PR 都收得下:它先讀議題本身,是 PR 才會再去翻 review。純議題的 `類型` 是 `議題`,
|
||||
留言的 `類型` 都是 `一般`。`已處理` 為 `true` 的跳過——那是前幾輪整併過的。
|
||||
|
||||
`已處理` 認的是**自己打的** `+1`。別人按讚是「我同意」,不是「這則已經收進描述了」。
|
||||
|
||||
## 2. 挑出真正的決策
|
||||
|
||||
**不是每一則留言都要整併。** 逐則判斷它有沒有改變「這顆議題現在說的事」:
|
||||
|
||||
- **要整併** — 改變了目標、範圍、做法、驗收標準;補上了原本沒寫的限制;推翻了描述裡的假設。
|
||||
- **不整併** — 提問與答覆、進度回報、「收到」、與內容無關的討論、已經反映在描述裡的事。
|
||||
|
||||
判斷不了的**當成要整併**,然後在下一步問使用者——漏掉一個決策,描述就會繼續騙後面的人。
|
||||
|
||||
## 3. 提出整併方案,讓使用者點頭
|
||||
|
||||
**先列出來再動手。** 對每一則要整併的留言,說明:
|
||||
|
||||
- 它說了什麼(一句話)
|
||||
- 要併進**哪一段**(`總覽`/`目標`/`非目標`/`驗收標準`/`未決事項`…)
|
||||
- 那一段**改完長什麼樣**
|
||||
|
||||
然後一次問一題,兩個選項:
|
||||
|
||||
- **整併** — 照你提的方案寫回去。
|
||||
- **略過** — 這一則不併。**略過的留言保持未標記**,下次執行還會被提出來。
|
||||
|
||||
使用者要改你的寫法時,照他說的改。這一步是議題描述的最後一道關卡——寫進去之後,
|
||||
後面的人就是拿它當事實。
|
||||
|
||||
## 4. 逐段寫回
|
||||
|
||||
一段一段來。同一段有多則留言的決策就先合併成一份內容,一次寫回:
|
||||
|
||||
```
|
||||
node scripts/comments-merge.js --repo <owner/name> --index <編號> \
|
||||
--section <段落名> --content-file <暫存檔> --merged <該段的留言 id> --dry-run
|
||||
```
|
||||
|
||||
`--content-file` 是**那一段改完的完整內容**(不含 `## 標題` 那一行)。腳本只換那一段,
|
||||
其餘一字不動。
|
||||
|
||||
`--merged` 只放**真的被併進這一段**的留言 id。略過的不要放進去——放了就等於這則再也
|
||||
不會被提出來。
|
||||
|
||||
試跑會印出改完的描述與將標記的留言。確認無誤後拿掉旗標再跑一次。
|
||||
|
||||
腳本先寫描述再標記,描述寫失敗就不標記。`SECTION_NOT_FOUND` 表示段落名與議題上的
|
||||
`## 標題` 對不上,**不要改用別的段落硬塞**,回頭確認名稱。
|
||||
|
||||
## 5. 回報
|
||||
|
||||
- 幾則留言、整併了幾則、略過幾則
|
||||
- 改了哪幾段,各自併進了什麼
|
||||
- 略過的那幾則是哪些(**要列出來**,讓使用者知道它們下次還會出現)
|
||||
|
||||
**略過的留言會讓 `未處理留言數` 停在大於 0。** 那是刻意的——下次還要被提出來。但它也表示
|
||||
`/sdlc-analyze` 與 `/sdlc-feat` 每次開始時都會再停一次。回報時要講明白這件事,讓使用者知道
|
||||
那不是沒整併乾淨,而是他選了略過。
|
||||
|
||||
## 接回原本的指令
|
||||
|
||||
這個指令常常不是使用者自己叫的,而是 `/sdlc-analyze`、`/sdlc-feat` 或 `/sdlc-fix` 發現有
|
||||
未整併留言後轉過來的。**整併完就直接接回去**,從原本那個指令被打斷的地方繼續,不要要求使用者重打一次。
|
||||
|
||||
接回去之前先重跑一次抽取(`issue-extract`/`wp-extract`),拿到的才是剛更新過的描述——
|
||||
接著用舊的那一份做事,這一整段就白做了。
|
||||
|
||||
## 邊界
|
||||
|
||||
- 不自行決定要不要整併:方案一定先給使用者看過。
|
||||
- 不整份重寫描述,只換談好的那幾段。
|
||||
- 不標記略過的留言。
|
||||
- 不刪除、不編輯任何留言——留言是誰說過什麼的紀錄,整併是把結論抄進描述,不是把原文搬走。
|
||||
- 不因為整併而改變議題的狀態、標籤或指派。
|
||||
@@ -0,0 +1,57 @@
|
||||
# 實作規範
|
||||
|
||||
改目標專案的程式碼時照這份做。這份規則只存在於本 plugin 裡,**不寫入目標專案的任何檔案**
|
||||
——目標專案的 `CLAUDE.md`、`AGENTS.md` 與設定檔一律不碰。
|
||||
|
||||
## 先認語言,再動手
|
||||
|
||||
改任何一個檔案之前,先從專案檔認出這是什麼語言:
|
||||
|
||||
| 專案檔 | 語言 |
|
||||
| --- | --- |
|
||||
| `*.csproj`、`*.sln` | C# |
|
||||
| `composer.json` | PHP |
|
||||
| `package.json` | JavaScript/TypeScript |
|
||||
| `go.mod` | Go |
|
||||
| `pom.xml`、`build.gradle` | Java |
|
||||
| `pyproject.toml`、`setup.py` | Python |
|
||||
|
||||
認出來之後,對照 `references/comment-styles.md` 取得該語言的註解格式。
|
||||
|
||||
**認不出來就停下來問,不要猜。** 猜錯的代價是滿檔案格式不對的註解,比沒有註解更難清理。
|
||||
同一個 repo 裡有多種語言時,以**正在改的那個檔案**所屬的語言為準。
|
||||
|
||||
## 分層看職責,不看目錄
|
||||
|
||||
目錄名稱會騙人:叫 `services/` 的資料夾裡常有一半是控制層。判斷依據一律是**這段程式在做什麼**。
|
||||
|
||||
| 層 | 怎麼認 | 要寫什麼註解 |
|
||||
| --- | --- | --- |
|
||||
| 控制層 | 對外的介面:HTTP handler、CLI 進入點、事件訂閱者、對外 API | **功能註解**——這個介面在做什麼、誰會呼叫它 |
|
||||
| 服務層 | 所有邏輯:判斷、計算、流程編排 | **邏輯註解**——這段邏輯在解決什麼問題,並**標註它呼叫的所有方法** |
|
||||
| 存取層 | 任何碰資料來源的東西:DB、外部 API、檔案、快取、訊息佇列 | **資料源註解**——資料從哪裡來、是哪一張表/哪一支 API |
|
||||
|
||||
服務層要標註呼叫的方法,是為了讓 reviewer **追得到呼叫鏈**:看一個方法就知道它會往下走到哪裡,
|
||||
不必逐層點開。
|
||||
|
||||
## 屬性一律要有用途註解
|
||||
|
||||
每一個屬性都寫它的用途。**屬性本身是類別時遞迴處理**——巢狀結構的每一層都要有,
|
||||
不能只註解最外層然後說「詳見該類別」。
|
||||
|
||||
用途註解要附**真實的資料範例**,讓人知道實際格式長什麼樣(是 `2026-09-17` 還是
|
||||
`2026/09/17`,是 `TWD` 還是 `NTD`)。
|
||||
|
||||
範例的來源有優先順序:
|
||||
|
||||
1. **優先從 MCP 取得**——能連到真實資料來源時,取真的值。
|
||||
2. 取不到就以邏輯推理,並**明確註明「由邏輯推理、未經驗證」**。
|
||||
|
||||
註明這件事不能省。未經驗證的範例本身有用,但讓人誤以為它經過驗證就會出事——
|
||||
有人會照著那個格式寫解析。
|
||||
|
||||
## 邊界
|
||||
|
||||
- 不改與這次待辦無關的程式碼。看到順手想修的東西,記下來、說出來,不要摸進這次的變更裡。
|
||||
- 不動目標專案的設定檔、CI 設定與相依版本,除非待辦本身就是在做那件事。
|
||||
- 既有程式碼的註解不符合這份規範時,**只補你改到的那些**,不要順手重寫整個檔案。
|
||||
@@ -0,0 +1,110 @@
|
||||
# 註解格式對照表
|
||||
|
||||
各語言的註解怎麼寫。先用 `references/coding-standards.md` 的專案檔對照認出語言,再查這裡。
|
||||
|
||||
規範本身(哪一層寫什麼、屬性要附真實資料範例)在 `coding-standards.md`,這份只管**格式**。
|
||||
|
||||
## C#
|
||||
|
||||
XML 文件註解,`///` 起頭。屬性用 `<summary>`,範例寫在 `<example>` 或 summary 末尾。
|
||||
|
||||
```csharp
|
||||
/// <summary>依訂單編號取回訂單主檔。呼叫 OrderRepository.FindById。</summary>
|
||||
/// <param name="orderId">訂單編號,例如 "ORD-20260917-0012"</param>
|
||||
public Order GetOrder(string orderId)
|
||||
|
||||
/// <summary>成立時間,ISO 8601 帶時區。例:2026-09-17T14:03:00+08:00</summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
```
|
||||
|
||||
## PHP
|
||||
|
||||
PHPDoc,`/** */`。屬性用 `@var`,範例接在說明後面。
|
||||
|
||||
```php
|
||||
/**
|
||||
* 依訂單編號取回訂單主檔。呼叫 OrderRepository::findById()。
|
||||
*
|
||||
* @param string $orderId 訂單編號,例如 "ORD-20260917-0012"
|
||||
*/
|
||||
public function getOrder(string $orderId): Order
|
||||
|
||||
/** @var string 幣別代碼,ISO 4217。例:TWD */
|
||||
private string $currency;
|
||||
```
|
||||
|
||||
## JavaScript/TypeScript
|
||||
|
||||
JSDoc,`/** */`。TypeScript 本身已經有型別,所以註解只寫**用途與範例**,不要複述型別。
|
||||
|
||||
```js
|
||||
/**
|
||||
* 依訂單編號取回訂單主檔。呼叫 orderRepository.findById。
|
||||
* @param {string} orderId 訂單編號,例如 "ORD-20260917-0012"
|
||||
*/
|
||||
async function getOrder(orderId)
|
||||
|
||||
/** 幣別代碼,ISO 4217。例:TWD */
|
||||
currency;
|
||||
```
|
||||
|
||||
## Go
|
||||
|
||||
`//` 起頭,**以被註解的識別字開頭**(Go 的慣例,`go doc` 會照這個排版)。
|
||||
|
||||
```go
|
||||
// GetOrder 依訂單編號取回訂單主檔。呼叫 orderRepo.FindByID。
|
||||
func GetOrder(orderID string) (*Order, error)
|
||||
|
||||
type Order struct {
|
||||
// Currency 是幣別代碼,ISO 4217。例:TWD
|
||||
Currency string
|
||||
}
|
||||
```
|
||||
|
||||
## Java
|
||||
|
||||
Javadoc,`/** */`。
|
||||
|
||||
```java
|
||||
/**
|
||||
* 依訂單編號取回訂單主檔。呼叫 OrderRepository#findById。
|
||||
*
|
||||
* @param orderId 訂單編號,例如 "ORD-20260917-0012"
|
||||
*/
|
||||
public Order getOrder(String orderId)
|
||||
|
||||
/** 幣別代碼,ISO 4217。例:TWD */
|
||||
private String currency;
|
||||
```
|
||||
|
||||
## Python
|
||||
|
||||
docstring,`"""..."""`,寫在定義的**下一行**(不是上一行)。屬性用行內 `#` 或 dataclass 的 docstring。
|
||||
|
||||
```python
|
||||
def get_order(order_id: str) -> Order:
|
||||
"""依訂單編號取回訂單主檔。呼叫 OrderRepository.find_by_id。
|
||||
|
||||
Args:
|
||||
order_id: 訂單編號,例如 "ORD-20260917-0012"
|
||||
"""
|
||||
|
||||
@dataclass
|
||||
class Order:
|
||||
currency: str # 幣別代碼,ISO 4217。例:TWD
|
||||
```
|
||||
|
||||
## 未經驗證的範例怎麼標
|
||||
|
||||
範例取不到真實來源時,照該語言的格式把註明寫進註解裡,**不要另起一行 TODO**:
|
||||
|
||||
```js
|
||||
/** 幣別代碼,ISO 4217。例:TWD(由邏輯推理、未經驗證) */
|
||||
```
|
||||
|
||||
```python
|
||||
currency: str # 幣別代碼,ISO 4217。例:TWD(由邏輯推理、未經驗證)
|
||||
```
|
||||
|
||||
這句話要留在程式碼裡,讓後面的人知道這個格式還沒有人對過。
|
||||
@@ -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 的推測寫成使用者已確認的需求。
|
||||
|
||||
## 回答狀態
|
||||
|
||||
- 具體回答可填入工作稿,並重新檢查所有必要維度與情境。
|
||||
- 使用者明確確認「不適用」且提供原因,可標記該項已完成。
|
||||
- 未回答、拒絕回答、「不知道」或「尚未決定」都仍是缺口,不得填成已確認。
|
||||
- 仍有缺口時不得建立需求議題;應繼續一次問一題。
|
||||
|
||||
## 建立前完整性閘門
|
||||
|
||||
建立需求議題前必須確認:
|
||||
|
||||
- 九個段落都有實質內容,或使用者已確認不適用並說明原因。
|
||||
- 需求的角色、情境、行為與可觀察結果完整。
|
||||
- 主要成功情境已確認;適用的失敗與邊界情境已確認。
|
||||
- 沒有未回答或「尚未決定」的阻塞缺口。
|
||||
- 驗收標準描述外部可觀察結果,不綁定不必要的實作細節。
|
||||
- 內容前後一致,且非目標足以限制範圍。
|
||||
|
||||
通過閘門後才可套用需求議題模板、查標籤與建立議題。
|
||||
+69
-122
@@ -1,6 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 備妥開工的分支。
|
||||
* 備妥開工的分支與工作樹。
|
||||
*
|
||||
* 每顆工作包在一棵屬於自己的工作樹上開工,不在原地切換分支。這樣同時持有幾顆工作包
|
||||
* 都互不干擾:各自的未提交變更、各自的建置產物,而 agent 無論什麼時候去讀檔,
|
||||
* 都只會讀到它該讀的那份程式碼——agent 是非同步的,它不會察覺自己讀到的是別顆工作包
|
||||
* 的內容,產出看起來完全合理,只是接錯了上下文。
|
||||
*
|
||||
* 工作樹一律建立,沒有例外。建不起來就明確中止,不默默退回原地切分支:靜默降級會讓
|
||||
* 使用者以為自己在隔離環境裡,其實在原地改。
|
||||
*
|
||||
* 命名規則(議題 #1 的正本):
|
||||
* - 從開發分支長出 → `{類型}/{英文-kebab-需求描述}/main`
|
||||
@@ -10,22 +18,42 @@
|
||||
* 需求描述由議題標題翻譯——那是 agent 的事,不是腳本的事,所以這裡只收 `--slug`
|
||||
* 並驗格式:英文 kebab、≤40 字元。中文分支名會讓 CI 與 URL 出問題,擋在建立之前。
|
||||
*
|
||||
* 三處「不弄丟別人的東西」:
|
||||
* - 工作區不乾淨就不動手,免得把不相干的改動帶進這顆工作包的分支。
|
||||
* - 來源分支在遠端已存在時 pull 而不是重建。
|
||||
* - 目標分支已存在時接上去而不是從來源蓋掉。
|
||||
* 三種情況都在任何 git 寫入之前判斷完:試跑印得出漂亮的計畫、實跑卻中途炸掉,
|
||||
* 是最難查的那種落差。
|
||||
* 建分支與建工作樹是同一個原子動作:先 `git fetch` 更新遠端引用,再以一次
|
||||
* `git worktree add` 完成。起點一律取自 `origin/{來源分支}`,遠端沒有就中止,
|
||||
* 不退回本機同名分支——那是靜默降級,而且後果隱蔽:使用者以為自己從最新的遠端狀態
|
||||
* 開工,實際上起點可能落後好幾天。
|
||||
*
|
||||
* **不設 upstream**。此刻遠端還沒有這個新分支,`--track` 會把 upstream 指到*來源分支*,
|
||||
* 之後 `git pull` 會把來源分支的提交拉進來,幾乎一定不是使用者要的。
|
||||
* upstream 留給第一次 `push -u` 自然建立。
|
||||
*
|
||||
* 兩處「不弄丟別人的東西」:目標分支已存在時接上去而不是從來源蓋掉;推導出的路徑被
|
||||
* 別的東西佔住時中止而不是硬蓋過去。兩者都在任何 git 寫入之前判斷完:試跑印得出
|
||||
* 漂亮的計畫、實跑卻中途炸掉,是最難查的那種落差。
|
||||
*
|
||||
* 算指令、跑指令與失敗回滾都在 lib 的 `planWorktree`/`createWorktree`——重建工作樹
|
||||
* (`worktree-ensure`)走的是同一套,兩邊各寫一份遲早會在「起點取自哪裡」這種地方分岔。
|
||||
*
|
||||
* 工作樹路徑由 `owner/repo/分支名` 純函式推導(見 lib 的 worktreePath),
|
||||
* 不寫任何本機狀態檔:換機器或換 agent 都能接手,進度只從 Gitea 與 git 本身推導。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/branch-prep.js --source <來源分支> --slug <英文-kebab>
|
||||
* node scripts/branch-prep.js --repo <owner/name> --source <來源分支> --slug <英文-kebab>
|
||||
* [--type feat] [--path <目標專案>] [--dry-run]
|
||||
*/
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { ScriptError, main, parseFlags, runGit } from './lib.js';
|
||||
import {
|
||||
ScriptError,
|
||||
createWorktree,
|
||||
main,
|
||||
openGitRepo,
|
||||
parseFlags,
|
||||
parseRepo,
|
||||
planWorktree,
|
||||
requireOrigin,
|
||||
worktreePath,
|
||||
} from './lib.js';
|
||||
|
||||
/** 需求描述的長度上限。超過就換一個短的說法,不要靠截斷。 */
|
||||
const SLUG_MAX = 40;
|
||||
@@ -33,67 +61,49 @@ const SLUG_MAX = 40;
|
||||
/** 功能分支長這樣:三段、前兩段非空。開發分支(master/main/develop)不合這個樣式。 */
|
||||
const FEATURE_BRANCH = /^([a-z]+)\/([^/]+)\/([^/]+)$/;
|
||||
|
||||
/**
|
||||
* 專案檔 → 把依賴裝起來的指令。
|
||||
* 工作樹是乾淨的,這份對照表只用來提示使用者該跑什麼,腳本自己不執行安裝——
|
||||
* 在別人的機器上裝東西應該是他自己的決定。偵測不到就不提,猜錯的指令比沒有更浪費時間。
|
||||
*/
|
||||
const INSTALL_HINTS = [
|
||||
['package.json', 'npm install'],
|
||||
['composer.json', 'composer install'],
|
||||
['requirements.txt', 'pip install -r requirements.txt'],
|
||||
['go.mod', 'go mod download'],
|
||||
['Gemfile', 'bundle install'],
|
||||
['Cargo.toml', 'cargo fetch'],
|
||||
];
|
||||
|
||||
const CLEAN_WORKTREE =
|
||||
'這是一棵乾淨的工作樹:沒有安裝依賴,也沒有任何建置產物。' +
|
||||
'.env 這類機密檔案一律不自動複製,需要的話請自己放一份。';
|
||||
|
||||
main(async () => {
|
||||
const flags = parseFlags(process.argv.slice(2), {
|
||||
required: ['source', 'slug'],
|
||||
required: ['repo', 'source', 'slug'],
|
||||
optional: ['type', 'path'],
|
||||
booleans: ['dry-run'],
|
||||
});
|
||||
const repo = parseRepo(flags.repo);
|
||||
const path = flags.path ?? process.cwd();
|
||||
const source = flags.source;
|
||||
const branch = buildBranchName(source, flags.slug, flags.type);
|
||||
const worktree = worktreePath(repo, branch);
|
||||
|
||||
if (!existsSync(join(path, '.git'))) {
|
||||
throw new ScriptError('NOT_A_GIT_REPO', `${path} 不是 git repo;請用 --path 指向目標專案的根目錄`);
|
||||
}
|
||||
const git = openGitRepo(path);
|
||||
requireOrigin(git, path, '工作樹的起點一律取自 origin/{來源分支}');
|
||||
|
||||
const git = (...args) => runGit(args, { cwd: path });
|
||||
checkClean(git, path);
|
||||
|
||||
const hasOrigin = git('remote').split('\n').includes('origin');
|
||||
/**
|
||||
* 比對用全名 `refs/heads/<ref>`:`ls-remote --heads origin main` 的樣式比對吃的是
|
||||
* ref 的尾段,而本 repo 的命名慣例讓每一支分支都以 `/main` 結尾——用短名比對,
|
||||
* 拿 main 當開發分支的專案會整個誤判成「遠端已經有這一支」。
|
||||
*/
|
||||
const onRemote = (ref) =>
|
||||
hasOrigin && git('ls-remote', '--heads', 'origin', `refs/heads/${ref}`).trim() !== '';
|
||||
const onLocal = (ref) => git('branch', '--list', ref).trim() !== '';
|
||||
|
||||
const sourcePlan = syncPlan(source, onRemote(source), onLocal(source), {
|
||||
missing: () => {
|
||||
throw new ScriptError(
|
||||
'SOURCE_NOT_FOUND',
|
||||
`來源分支 ${source} 在本地與遠端都不存在;請確認分支名,或先把它推上遠端`,
|
||||
);
|
||||
},
|
||||
});
|
||||
const branchPlan = syncPlan(branch, onRemote(branch), onLocal(branch), {
|
||||
// 目標分支不存在是常態:這就是開一支新分支
|
||||
missing: () => ({ commands: [['checkout', '-b', branch]], 動作: '從來源建立' }),
|
||||
onRemote: '接上遠端既有',
|
||||
onLocal: '切換到本地既有',
|
||||
});
|
||||
|
||||
const commands = [...sourcePlan.commands, ...branchPlan.commands];
|
||||
const 來源 = { 位置: sourcePlan.位置, 動作: sourcePlan.動作 };
|
||||
const 分支 = { 動作: branchPlan.動作 };
|
||||
const plan = planWorktree(git, { source, branch, worktree });
|
||||
const 報告 = { path, repo, source, branch, worktree, 分支: { 動作: plan.動作 } };
|
||||
|
||||
if (flags['dry-run']) {
|
||||
return {
|
||||
dryRun: true,
|
||||
path,
|
||||
source,
|
||||
branch,
|
||||
來源,
|
||||
分支,
|
||||
commands: commands.map((args) => `git ${args.join(' ')}`),
|
||||
};
|
||||
return { dryRun: true, ...報告, commands: plan.commands.map((args) => `git ${args.join(' ')}`) };
|
||||
}
|
||||
|
||||
for (const args of commands) runGitStep(git, args, source);
|
||||
createWorktree(git, plan, { worktree, branch });
|
||||
|
||||
return { path, source, branch, 來源, 分支 };
|
||||
return { ...報告, 提示: { 訊息: CLEAN_WORKTREE, 安裝指令: installHints(worktree) } };
|
||||
});
|
||||
|
||||
|
||||
@@ -159,72 +169,9 @@ function isKebab(value) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作區必須乾淨才開工。
|
||||
*
|
||||
* 未提交的改動與未追蹤的檔案都會跟著 checkout 走到新分支上,混進這顆工作包的 commit
|
||||
* 裡;而目標分支已存在時,git 還會在 checkout 那一步才拒絕,屆時 fetch 與 merge
|
||||
* 都已經跑掉了,留下做到一半的狀態。寧可一開始就擋。
|
||||
* 這棵工作樹要怎麼把依賴裝起來。
|
||||
* 偵測不到就回空陣列,不亂猜——猜錯的指令比沒有指令更浪費時間。
|
||||
*/
|
||||
function checkClean(git, path) {
|
||||
const dirty = git('status', '--porcelain');
|
||||
if (dirty !== '') {
|
||||
const files = dirty
|
||||
.split('\n')
|
||||
.map((line) => line.slice(3))
|
||||
.join('、');
|
||||
throw new ScriptError(
|
||||
'DIRTY_WORKTREE',
|
||||
`${path} 的工作區還有未處理的變更(${files});` +
|
||||
'請先提交、暫存(git stash)或清掉,再來開分支',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 算出要把一支 ref 弄到手需要哪幾個 git 指令。
|
||||
*
|
||||
* 來源分支與目標分支的處理是同一個形狀——遠端有就 pull、只有本地就切過去——
|
||||
* 差別只在「兩邊都沒有」時怎麼辦,所以那一段由呼叫端給。
|
||||
*
|
||||
* 分成「算」與「做」兩段,`--dry-run` 才能印出真正將執行的 git 指令,
|
||||
* 而不是另外維護一份描述——兩邊分開寫就會走鐘。
|
||||
*
|
||||
* @param {(ref: string) => {commands: string[][], 動作: string}} handlers.missing 兩邊都沒有時
|
||||
*/
|
||||
function syncPlan(ref, onRemote, onLocal, handlers) {
|
||||
if (onRemote) {
|
||||
// 遠端已經有了就 pull,不重建:別人推上去的進度要帶進來
|
||||
const commands = [['fetch', 'origin', ref]];
|
||||
commands.push(onLocal ? ['checkout', ref] : ['checkout', '-b', ref, `origin/${ref}`]);
|
||||
if (onLocal) commands.push(['merge', '--ff-only', `origin/${ref}`]);
|
||||
return { commands, 位置: '遠端', 動作: handlers.onRemote ?? 'pull' };
|
||||
}
|
||||
if (onLocal) {
|
||||
return {
|
||||
commands: [['checkout', ref]],
|
||||
位置: '本地',
|
||||
動作: handlers.onLocal ?? '用本地既有',
|
||||
};
|
||||
}
|
||||
return handlers.missing(ref);
|
||||
}
|
||||
|
||||
/**
|
||||
* 跑一個 git 步驟,並把已知會發生的失敗換成看得懂的錯誤碼。
|
||||
* `merge --ff-only` 失敗幾乎都是同一件事:本地有沒推上去的 commit,而遠端也往前走了。
|
||||
* 原始的 git 訊息說得不夠白,使用者需要知道下一步是 rebase 還是先推。
|
||||
*/
|
||||
function runGitStep(git, args, source) {
|
||||
try {
|
||||
return git(...args);
|
||||
} catch (error) {
|
||||
if (args[0] === 'merge') {
|
||||
throw new ScriptError(
|
||||
'SOURCE_DIVERGED',
|
||||
`${source} 的本地與遠端已經分歧,無法直接快轉;` +
|
||||
'請先把本地的 commit 推上去或 rebase 到遠端之後,再執行一次',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
function installHints(worktree) {
|
||||
return INSTALL_HINTS.filter(([file]) => existsSync(join(worktree, file))).map(([, command]) => command);
|
||||
}
|
||||
|
||||
+3
-46
@@ -1,15 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 領取一顆工作包:上鎖、貼標籤、起錶。
|
||||
* 領取一顆工作包:上鎖、貼標籤。
|
||||
*
|
||||
* 鎖用 assignee 加標籤,不用碼錶——Gitea 只讓人讀自己的碼錶(`/user/stopwatches`),
|
||||
* 看不到別人的錶,拿它當鎖會漏判。碼錶在這裡只有一個用途:發現自己忘了停掉上一顆。
|
||||
* 鎖用 assignee 加標籤,不用碼錶;碼錶與耗時統計已移除。
|
||||
*
|
||||
* 四種狀態的處置:
|
||||
* - 他人已認領 → 擋。不會兩個人做同一件事。
|
||||
* - 自己的錶跑在本議題 → 擋。這顆你已經在做了,別重複起錶。
|
||||
* - 自己的錶跑在別的議題 → 擋。先去停掉那一顆,否則工時會記錯地方。
|
||||
* - 沒有鎖(含自己已認領沒錶)→ 放行。後者正是中斷後重跑的情形。
|
||||
* 他人已認領時擋下,自己已認領時可冪等重跑;所有判斷都在寫入前完成。
|
||||
*
|
||||
* 所有會擋的判斷都做在任何寫入之前:擋下來卻已經改了一半,比直接放行更難收拾。
|
||||
* `--dry-run` 走的是同一條路,只是停在寫入之前——它印出的是這一顆此刻真正缺的那幾步,
|
||||
@@ -56,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) {
|
||||
@@ -84,9 +75,6 @@ main(async () => {
|
||||
});
|
||||
labels.push(IN_PROGRESS);
|
||||
}
|
||||
// 錶最後才起:前面任一步失敗時,不該留下一顆還在跑的碼錶
|
||||
planned.push({ method: 'POST', path: `${issuePath}/stopwatch/start`, body: {} });
|
||||
|
||||
if (dryRun) {
|
||||
return { dryRun: true, repo, index, title: issue.title, requests: planned, 已認領過 };
|
||||
}
|
||||
@@ -102,7 +90,6 @@ main(async () => {
|
||||
url: issue.html_url,
|
||||
assignee: me,
|
||||
labels,
|
||||
碼錶中: true,
|
||||
已認領過,
|
||||
};
|
||||
});
|
||||
@@ -120,33 +107,3 @@ function checkClaimable(assignees, me, index) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自己的錶跑在任何議題上都擋,需要手動停錶後再領。
|
||||
*
|
||||
* 不代勞停錶:那一段時間該記在哪顆議題上只有人知道,腳本自作主張會把工時記錯地方。
|
||||
* 錯誤碼分兩種,因為使用者的下一步不同——跑在本議題是「你已經在做了」,
|
||||
* 跑在別的議題是「你忘了停掉那一顆」。
|
||||
*/
|
||||
async function checkNoStopwatch(login, repo, index) {
|
||||
const path = '/user/stopwatches';
|
||||
const watches = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? [];
|
||||
if (watches.length === 0) return;
|
||||
|
||||
const here = watches.find(
|
||||
(watch) => `${watch.repo_owner_name}/${watch.repo_name}` === repo && watch.issue_index === index,
|
||||
);
|
||||
if (here) {
|
||||
throw new ScriptError(
|
||||
'STOPWATCH_ON_THIS_ISSUE',
|
||||
`你的碼錶已經跑在議題 #${index} 上,這顆你正在做;` +
|
||||
'若要重新計時,請先在 Gitea 上手動停錶再執行一次',
|
||||
);
|
||||
}
|
||||
|
||||
const elsewhere = watches[0];
|
||||
throw new ScriptError(
|
||||
'STOPWATCH_ON_OTHER_ISSUE',
|
||||
`你的碼錶正跑在 ${elsewhere.repo_owner_name}/${elsewhere.repo_name} 的議題 ` +
|
||||
`#${elsewhere.issue_index} 上,領取前請先手動停錶,否則工時會記到那一顆去`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 把留言裡的決策整併回議題描述,並標記那幾則留言。
|
||||
*
|
||||
* 判斷「哪幾則留言有決策、該併進哪一段、併成什麼樣子」是讀得懂內容的人的事;
|
||||
* 這一支只負責把結果安全地寫回去。
|
||||
*
|
||||
* 兩件事錯了都很安靜,所以都做得很窄:
|
||||
*
|
||||
* - **局部更新。** 只換指定那一段,標題與其餘段落一字不動。整份重寫會把別人在其他
|
||||
* 段落的編輯一起蓋掉,而議題的編輯紀錄沒有人會去比對。
|
||||
* - **標記只給真的整併進去的那幾則。** 略過的要保持未標記,下次才會再被提出來;
|
||||
* 描述沒寫成功就不標記——標了就等於這則再也不會被看到。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/comments-merge.js --repo owner/name --index 7
|
||||
* --section <段落名> --content-file <檔案> --merged 101,102
|
||||
* [--host <網址>] [--dry-run]
|
||||
*/
|
||||
import {
|
||||
ScriptError,
|
||||
expectOk,
|
||||
fetchIssue,
|
||||
giteaRequest,
|
||||
listIssueComments,
|
||||
main,
|
||||
parseFlags,
|
||||
parseIndex,
|
||||
parseRepo,
|
||||
preflight,
|
||||
readTextFile,
|
||||
resolveLogin,
|
||||
} from './lib.js';
|
||||
import { replaceSection } from './issue-body.js';
|
||||
|
||||
main(async () => {
|
||||
const flags = parseFlags(process.argv.slice(2), {
|
||||
required: ['repo', 'index', 'section', 'content-file', 'merged'],
|
||||
optional: ['host'],
|
||||
booleans: ['dry-run'],
|
||||
});
|
||||
const repo = parseRepo(flags.repo);
|
||||
const index = parseIndex(flags.index);
|
||||
const section = flags.section;
|
||||
const content = readTextFile(flags['content-file'], '--content-file');
|
||||
const merged = parseMerged(flags.merged);
|
||||
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 issuePath = `/repos/${repo}/issues/${index}`;
|
||||
|
||||
const result = replaceSection(issue.body ?? '', section, content);
|
||||
if (result.status === 'not-found') {
|
||||
throw new ScriptError(
|
||||
'SECTION_NOT_FOUND',
|
||||
`議題 #${index} 上沒有「${section}」這個段落;請確認段落名與議題上的 \`## 標題\` 完全一致。` +
|
||||
'本工具不會把內容補到議題末尾——段落名打錯時那樣做比什麼都不做更難收拾',
|
||||
);
|
||||
}
|
||||
if (result.status === 'ambiguous') {
|
||||
throw new ScriptError(
|
||||
'SECTION_AMBIGUOUS',
|
||||
`議題 #${index} 上有 ${result.count} 個「${section}」段落,分不出要換哪一個;` +
|
||||
'請先到議題上把重複的標題改成看得出差別的名稱',
|
||||
);
|
||||
}
|
||||
const body = result.body;
|
||||
|
||||
// 標記之前先確認這幾則留言真的在這顆議題上:標錯地方的 reaction 很難發現
|
||||
await checkComments(login, repo, index, merged);
|
||||
|
||||
// 描述沒變就不送:空的 PATCH 會把議題的 updated_at 推新,看起來像有人動過
|
||||
const 描述已更新 = body !== issue.body;
|
||||
const requests = [
|
||||
...(描述已更新 ? [{ method: 'PATCH', path: issuePath, body: { body } }] : []),
|
||||
...merged.map((id) => ({
|
||||
method: 'POST',
|
||||
path: `/repos/${repo}/issues/comments/${id}/reactions`,
|
||||
body: { content: '+1' },
|
||||
})),
|
||||
];
|
||||
|
||||
if (dryRun) {
|
||||
return { dryRun: true, repo, index, section, 描述已更新, 已標記: merged, requests };
|
||||
}
|
||||
|
||||
// 順序是先寫描述再標記:標記是「這則已經收進去了」的結論,
|
||||
// 反過來的話,描述寫失敗時那幾則已經被標成處理過,再也不會被提出來
|
||||
for (const { method, path, body: payload } of requests) {
|
||||
expectOk(await giteaRequest(login, method, path, { body: payload }), `${method} ${path}`);
|
||||
}
|
||||
|
||||
return {
|
||||
repo,
|
||||
index,
|
||||
url: issue.html_url,
|
||||
section,
|
||||
描述已更新,
|
||||
已標記: merged,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
|
||||
/** 逗號分隔的留言 id。整併卻不標記的話,下次會重複處理同一則,所以這個 flag 是必填。 */
|
||||
function parseMerged(value) {
|
||||
const ids = value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== '')
|
||||
.map((item) => parseIndex(item, '--merged'));
|
||||
|
||||
if (ids.length === 0) {
|
||||
throw new ScriptError('MISSING_FLAG', '--merged 至少要有一則留言 id');
|
||||
}
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 確認這幾則留言都在這顆議題上。
|
||||
* 打錯 id 的 reaction 會落在別顆議題的留言上,而那幾乎不會有人發現。
|
||||
*/
|
||||
async function checkComments(login, repo, index, merged) {
|
||||
const seen = new Set();
|
||||
|
||||
for await (const comment of listIssueComments(login, repo, index)) {
|
||||
seen.add(comment.id);
|
||||
}
|
||||
|
||||
const missing = merged.filter((id) => !seen.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new ScriptError(
|
||||
'COMMENT_NOT_FOUND',
|
||||
`議題 #${index} 上沒有這幾則留言:${missing.join('、')};` +
|
||||
'請確認 id 來自這顆議題(必要時重跑 issue-extract 或 wp-extract)',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 把工作區的變更依類型分批 commit。
|
||||
*
|
||||
* 一個 commit 只裝一種類型:程式碼、測試、文件、雜項各自成批,reviewer 一次只看一件事,
|
||||
* 日後 `git log` 也讀得懂。全部混成一顆「完成工作包」的巨大 commit,等於沒有歷史。
|
||||
*
|
||||
* 類型多半看得出來——測試檔就是 test、README 就是 docs——但 `scripts/` 底下的改動
|
||||
* 是新功能還是修 bug,只有做的人知道,所以那一批由 `--type` 指定。
|
||||
*
|
||||
* 訊息格式 `{類型}({scope}): {繁中描述}`,`--body` 接在首行之後說明「為什麼這樣做」。
|
||||
* scope 單檔用檔名、多檔用 `--scope` 的功能名。
|
||||
* 描述要用繁體中文——日後回顧時看得懂的是中文,不是當初隨手寫的英文。
|
||||
*
|
||||
* 一次變更橫跨兩個不相干的功能時用 `--files` 分兩次跑:一顆 commit 的描述只說得清楚
|
||||
* 一件事,硬湊在一起就失去了分批的意義。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/commit-split.js --type feat --subject '<繁中描述>'
|
||||
* [--scope <功能名>] [--body '<為什麼>'] [--files a.js,b.js]
|
||||
* [--path <目標專案>] [--dry-run]
|
||||
*/
|
||||
import { basename } from 'node:path';
|
||||
import { ScriptError, main, openGitRepo, parseFlags } from './lib.js';
|
||||
|
||||
/** commit 訊息的類型。與既有 git 歷史一致,不另立新詞。 */
|
||||
const TYPES = ['feat', 'fix', 'refactor', 'test', 'docs', 'chore', 'perf', 'style'];
|
||||
|
||||
/**
|
||||
* 從檔案路徑看得出來的類型。由上往下比對,第一個命中的為準。
|
||||
*
|
||||
* 只列「看路徑就能確定」的那幾種。`scripts/`、`prompts/`、`references/`、`templates/`
|
||||
* 都是產品本身,是新增還是修正得由做的人說,所以不在這張表裡——它們吃 `--type`。
|
||||
*/
|
||||
const BY_PATH = [
|
||||
{
|
||||
// 目標專案的測試未必放在 test/:tests/、spec/、__tests__/ 都常見,
|
||||
// 也常見把 user.test.js 放在被測檔案旁邊
|
||||
type: 'test',
|
||||
match: (path) =>
|
||||
/(^|\/)(tests?|spec|__tests__)\//.test(path) || /\.(test|spec)\.[^./]+$/.test(path),
|
||||
},
|
||||
{ type: 'docs', match: (path) => /^[^/]+\.md$/.test(path) || path.startsWith('docs/') },
|
||||
{
|
||||
type: 'chore',
|
||||
match: (path) =>
|
||||
/^[^/]+$/.test(path) && !/\.md$/.test(path) && /^[.]|\.(json|ya?ml|toml|lock)$/.test(path),
|
||||
},
|
||||
{ type: 'chore', match: (path) => path.startsWith('.github/') || path.startsWith('.gitea/') },
|
||||
];
|
||||
|
||||
/** 分批的順序:先程式碼,再測試,最後周邊。git log 由新到舊讀起來才是「做了什麼、怎麼驗的」 */
|
||||
const ORDER = ['feat', 'fix', 'refactor', 'perf', 'style', 'test', 'docs', 'chore'];
|
||||
|
||||
main(async () => {
|
||||
const flags = parseFlags(process.argv.slice(2), {
|
||||
required: ['type', 'subject'],
|
||||
optional: ['scope', 'path', 'files', 'body'],
|
||||
booleans: ['dry-run'],
|
||||
});
|
||||
const path = flags.path ?? process.cwd();
|
||||
const type = parseType(flags.type);
|
||||
const subject = parseSubject(flags.subject);
|
||||
|
||||
const git = openGitRepo(path);
|
||||
const { changed, untracked } = changedFiles(git);
|
||||
if (changed.length === 0) {
|
||||
throw new ScriptError('NOTHING_TO_COMMIT', `${path} 的工作區是乾淨的,沒有東西可以提交`);
|
||||
}
|
||||
|
||||
const commits = plan(selectFiles(changed, flags.files), type, flags.scope, subject, flags.body);
|
||||
|
||||
if (flags['dry-run']) {
|
||||
return { dryRun: true, path, commits };
|
||||
}
|
||||
|
||||
const done = [];
|
||||
for (const { message, files } of commits) {
|
||||
try {
|
||||
// 只有未追蹤的檔案需要先 add:commit 帶 pathspec 不會把新檔案收進來,
|
||||
// 但已追蹤的修改與刪除它自己處理得了。對已經被 git rm 掉的檔案再 add 一次只會報
|
||||
// 「找不到這個路徑」——那個檔案本來就已經不在工作區也不在 index 裡了。
|
||||
const toAdd = files.filter((file) => untracked.has(file));
|
||||
if (toAdd.length > 0) git('add', '--', ...toAdd);
|
||||
git('commit', '-m', message, '--', ...files);
|
||||
done.push(message);
|
||||
} catch (cause) {
|
||||
// 不回捲已經建立的 commit:那會動到使用者的歷史,而這幾顆本身是好的。
|
||||
// 但一定要說出做到哪裡,否則重跑前得自己去翻 git log。
|
||||
throw new ScriptError(
|
||||
'COMMIT_FAILED',
|
||||
`這一批提交失敗:${message.split('\n')[0]}(${cause.message})。` +
|
||||
(done.length > 0
|
||||
? `在此之前已經建立:${done.map((m) => m.split('\n')[0]).join('、')};` +
|
||||
'修掉原因之後重跑即可,已建立的那幾顆不會重複。'
|
||||
: '還沒有任何 commit 被建立。'),
|
||||
);
|
||||
}
|
||||
}
|
||||
return { path, commits: commits.map(({ message, files }) => ({ message, files })) };
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* 列出工作區的變更檔案,含未追蹤與已刪除的。
|
||||
*
|
||||
* 刻意不用 `git status --porcelain`:它每一行的前兩欄是狀態碼,未 staged 的修改是
|
||||
* 「空格 M」開頭,而 runGit 會 trim 掉輸出的前導空白——第一行的狀態欄會少一格,
|
||||
* 切出來的檔名就少了第一個字元。改用兩個只印檔名的指令,不受 trim 影響。
|
||||
*
|
||||
* 未追蹤的那一份要單獨留著:提交時只有它們需要先 add。
|
||||
* @returns {{changed: string[], untracked: Set<string>}}
|
||||
*/
|
||||
function changedFiles(git) {
|
||||
// --no-renames 是必要的:git 預設偵測改名,只印出目的地那一個路徑,
|
||||
// 來源的刪除就會被漏掉——留在 index 裡沒被提交,而腳本還回報成功
|
||||
const tracked = git('diff', '--name-only', '--no-renames', 'HEAD')
|
||||
.split('\n')
|
||||
.filter((file) => file !== '');
|
||||
const untracked = git('ls-files', '--others', '--exclude-standard')
|
||||
.split('\n')
|
||||
.filter((file) => file !== '');
|
||||
|
||||
return {
|
||||
changed: [...new Set([...tracked, ...untracked])].sort(),
|
||||
untracked: new Set(untracked),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 挑出這一次要處理的檔案。沒給 `--files` 就是全部。
|
||||
* 指到沒有變更的檔案時報錯而不是略過——那多半是路徑打錯,默默少做一個檔案,
|
||||
* 要等 PR 開出去才會有人發現。
|
||||
*/
|
||||
function selectFiles(changed, files) {
|
||||
if (files === undefined) return changed;
|
||||
|
||||
const wanted = files.split(',').map((file) => file.trim()).filter((file) => file !== '');
|
||||
const missing = wanted.filter((file) => !changed.includes(file));
|
||||
if (missing.length > 0) {
|
||||
throw new ScriptError(
|
||||
'FILE_NOT_CHANGED',
|
||||
`--files 指到的這幾個檔案沒有變更:${missing.join('、')};請確認路徑(相對於 repo 根)`,
|
||||
);
|
||||
}
|
||||
return wanted.sort();
|
||||
}
|
||||
|
||||
/** 把變更分成幾批,每批一個 commit */
|
||||
function plan(changed, type, scope, subject, body) {
|
||||
const batches = new Map();
|
||||
for (const file of changed) {
|
||||
const batchType = classify(file) ?? type;
|
||||
if (!batches.has(batchType)) batches.set(batchType, []);
|
||||
batches.get(batchType).push(file);
|
||||
}
|
||||
|
||||
return ORDER.filter((batchType) => batches.has(batchType)).map((batchType) => {
|
||||
const files = batches.get(batchType);
|
||||
const first = `${batchType}(${scopeOf(files, scope)}): ${subject}`;
|
||||
// 同一次變更的每一批共用同一段說明:它們是同一件事的不同面向
|
||||
return { message: body === undefined ? first : `${first}\n\n${body.trim()}\n`, files };
|
||||
});
|
||||
}
|
||||
|
||||
/** 看路徑就能確定的類型;看不出來時回 null,由 --type 決定 */
|
||||
function classify(file) {
|
||||
return BY_PATH.find((rule) => rule.match(file))?.type ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 這一批的 scope。單檔時用檔名本身——它已經說明了改的是什麼;
|
||||
* 多檔時檔名沒有共同答案,得由呼叫端給一個功能名。
|
||||
*/
|
||||
function scopeOf(files, scope) {
|
||||
if (files.length === 1) return stemOf(files[0]);
|
||||
if (scope === undefined) {
|
||||
throw new ScriptError(
|
||||
'SCOPE_REQUIRED',
|
||||
`有一批是多檔(${files.join('、')}),scope 沒有辦法從檔名推得,請用 --scope 給一個功能名`,
|
||||
);
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* 檔名去掉所有副檔名。`claim.test.js` 的 scope 是 `claim` 而不是 `claim.test`——
|
||||
* 既有歷史裡測試的 scope 就是它測的那個東西的名字。
|
||||
* 隱藏檔(`.gitignore`)的開頭那一點是名字的一部分,不是副檔名。
|
||||
*/
|
||||
function stemOf(file) {
|
||||
const name = basename(file);
|
||||
const stem = name.startsWith('.') ? name.slice(1) : name;
|
||||
return stem.split('.')[0] || stem;
|
||||
}
|
||||
|
||||
function parseType(value) {
|
||||
if (!TYPES.includes(value)) {
|
||||
throw new ScriptError('BAD_TYPE', `--type 需為 ${TYPES.join('/')} 其中一個,收到的是 ${value}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 描述要有中文。這條規則擋的是「隨手寫一句英文」——日後回顧時看得懂的是中文,
|
||||
* 而混用英文名詞(函式名、旗標名)本來就該保留原文,所以只要求含有中文,不是全中文。
|
||||
*/
|
||||
function parseSubject(value) {
|
||||
const subject = value.trim();
|
||||
if (subject === '') {
|
||||
throw new ScriptError('BAD_SUBJECT', '--subject 不能是空的');
|
||||
}
|
||||
if (!/[一-鿿]/.test(subject)) {
|
||||
throw new ScriptError(
|
||||
'SUBJECT_NOT_CHINESE',
|
||||
`--subject 要用繁體中文描述這次改了什麼,收到的是「${subject}」;` +
|
||||
'夾雜英文的專有名詞沒問題,但整句英文日後回顧時讀起來最吃力',
|
||||
);
|
||||
}
|
||||
return subject;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
+157
-6
@@ -148,18 +148,17 @@ export function checklistInSection(body, section) {
|
||||
* @returns {{indent: number, value: {text: string, done: boolean, raw: string}}|null}
|
||||
*/
|
||||
function parseChecklistItem(line) {
|
||||
// [\s\S] 而非 . 的理由同 listSection:CRLF 的 body 行尾有 \r,. 不吃它。
|
||||
// text 靠 trim 修掉 \r,raw 則原樣留著——它要逐字等於 body 裡的那一行。
|
||||
const item = line.match(/^(\s*)(?:[-*+]|\d+\.)\s+([\s\S]*)$/);
|
||||
// 文法與 tickLine 共用 LIST_ITEM:抽得出來的行,勾選端就要收得下。
|
||||
// text 靠 trim 修掉 CRLF 的 \r,raw 則原樣留著——它要逐字等於 body 裡的那一行。
|
||||
const item = LIST_ITEM.exec(line);
|
||||
if (!item) return null;
|
||||
|
||||
const box = item[2].match(/^\[([ xX])\]\s*([\s\S]*)$/);
|
||||
const text = (box ? box[2] : item[2]).trim();
|
||||
const text = item[3].trim();
|
||||
if (text === '') return null;
|
||||
|
||||
return {
|
||||
indent: item[1].length,
|
||||
value: { text, done: box ? box[1].toLowerCase() === 'x' : false, raw: line },
|
||||
value: { text, done: item[2]?.toLowerCase() === '[x]', raw: line },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -183,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 同形狀,差別只在這裡要的是數量而非議題編號,所以認小數。
|
||||
@@ -259,6 +271,145 @@ function isSeparator(cells) {
|
||||
return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell));
|
||||
}
|
||||
|
||||
/**
|
||||
* 一行清單項的文法:符號或編號清單,後面可以有一個 checkbox。
|
||||
*
|
||||
* 全檔只有這一份定義。抽取端(parseChecklistItem)與勾選端(tickLine)若各寫一份,
|
||||
* 遲早會鬆緊不一——抽得出來卻勾不動的那一行,會讓「一律用 wp-extract 給的 raw」
|
||||
* 變成做不到的指示。
|
||||
*/
|
||||
const LIST_ITEM = /^(\s*)(?:[-*+]|\d+\.)\s+(\[[ xX]\])?\s*([\s\S]*)$/;
|
||||
|
||||
/**
|
||||
* 這一行是不是清單項(有沒有 checkbox 都算)。
|
||||
*
|
||||
* `--tick` 用它驗輸入,而且刻意不要求 checkbox:抽取端會把「忘了寫 checkbox 的待辦」
|
||||
* 也收成一項待辦,那種 raw 要走到 tickLine 才能得到「去議題上補成 checkbox」這句話,
|
||||
* 在入口就擋掉只會回一個看不出該怎麼辦的格式錯誤。
|
||||
* @param {string} line
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isListItem(line) {
|
||||
return LIST_ITEM.test(line);
|
||||
}
|
||||
|
||||
/**
|
||||
* 勾起一行 checkbox:把 `raw` 那一行的方框換成已勾,其餘一字不動。
|
||||
*
|
||||
* 三件事都限定在目標段落之內、且跳過圍欄,理由與 upsertLineInSection 相同——
|
||||
* 弄錯的代價是靜靜改壞別人的內容。勾選是這個檔案裡唯一會寫回議題的路徑,
|
||||
* 而工作包模板的架構圖就是一塊 fenced mermaid:裡面出現減號開頭的行是常態,
|
||||
* 把它當成待辦勾下去,改壞的是一張圖。
|
||||
*
|
||||
* 用整行精確比對而不是「找那段文字」,因為巢狀待辦底下常有一模一樣的驗收
|
||||
* (兩項待辦各有一條「加上測試」)。認不出是哪一行時交回 ambiguous 讓呼叫端報錯,
|
||||
* 不賭第一個——猜錯的話議題上的進度條會指著錯的那一項,而沒有人會去比對編輯紀錄。
|
||||
*
|
||||
* 勾選狀態與大小寫都不影響比對:`[ ]`、`[x]`、`[X]` 指的是同一行,
|
||||
* 已經勾過就交回 already,讓中斷後重跑是安靜的 no-op 而不是失敗。
|
||||
*
|
||||
* 本函式不拋錯——它是純解析,錯誤碼由呼叫端決定。
|
||||
*
|
||||
* @param {string} body 議題 body
|
||||
* @param {string} raw 抽取契約交出的原始 markdown 行,逐字包含縮排與行尾的 \r
|
||||
* @param {string} [section] 限定在這個段落內找;省略時找全文(圍欄照樣不算)
|
||||
* @returns {{status: 'ticked'|'already'|'not-found'|'ambiguous'|'no-checkbox'|'no-section', body?: string, line?: string, count: number}}
|
||||
*/
|
||||
export function tickLine(body, raw, section) {
|
||||
const item = LIST_ITEM.exec(raw);
|
||||
if (!item || item[2] === undefined) return { status: 'no-checkbox', count: 0 };
|
||||
|
||||
const rows = [...eachLine(body)];
|
||||
const { start, end } = section === undefined
|
||||
? { start: -1, end: rows.length }
|
||||
: sectionBounds(rows, section);
|
||||
if (section !== undefined && start === -1) return { status: 'no-section', count: 0 };
|
||||
|
||||
/** 同一行的三種寫法都指向它自己:比對時一律正規化成未勾的小寫版本 */
|
||||
const normalize = (line) => line.replace(/\[[ xX]\]/, '[ ]');
|
||||
const wanted = normalize(raw);
|
||||
const ticked = raw.replace(/\[[ xX]\]/, '[x]');
|
||||
|
||||
const hits = [];
|
||||
for (let i = start + 1; i < end; i += 1) {
|
||||
if (rows[i].inFence) continue;
|
||||
if (normalize(rows[i].line) === wanted) hits.push(i);
|
||||
}
|
||||
|
||||
if (hits.length === 0) return { status: 'not-found', count: 0 };
|
||||
if (hits.length > 1) return { status: 'ambiguous', count: hits.length };
|
||||
|
||||
const [at] = hits;
|
||||
// 已勾與否看方框本身,不比整行字串:`[X]` 是合法的 GFM,Gitea 也渲染成已勾,
|
||||
// 用字串相等判斷會把它當成還沒勾,於是重跑時硬把大寫改成小寫
|
||||
if (LIST_ITEM.exec(rows[at].line)[2].toLowerCase() === '[x]') {
|
||||
return { status: 'already', line: rows[at].line, count: 1 };
|
||||
}
|
||||
|
||||
const lines = rows.map((row) => row.line);
|
||||
lines[at] = ticked;
|
||||
return { status: 'ticked', body: lines.join('\n'), line: ticked, count: 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 換掉一個段落的內容,標題與其餘段落一字不動。
|
||||
*
|
||||
* 整併留言裡的決策時用它。不整份重寫的理由跟 upsertLineInSection 一樣,只是代價更大:
|
||||
* 重寫會把別人在其他段落的編輯一起蓋掉,而議題的編輯紀錄沒有人會去比對。
|
||||
*
|
||||
* 同名標題出現不只一次時交回 `ambiguous`,不賭第一個——理由與 tickLine 相同,
|
||||
* 而這裡蓋掉的是一整段而不是一行,猜錯的代價更高。
|
||||
*
|
||||
* 段落不存在時交回 `not-found` 讓呼叫端報錯,不補在結尾:「找不到那一段」多半是段落名
|
||||
* 打錯,這時把內容塞到議題末尾,比什麼都不做更難收拾。
|
||||
*
|
||||
* @param {string} body 議題 body
|
||||
* @param {string} section 段落名稱,例如 '目標'
|
||||
* @param {string} content 新的段落內容(不含 `## 標題` 那一行)
|
||||
* @returns {{status: 'replaced'|'not-found'|'ambiguous', body?: string, count: number}}
|
||||
*/
|
||||
export function replaceSection(body, section, content) {
|
||||
const rows = [...eachLine(body)];
|
||||
const headings = [];
|
||||
for (let i = 0; i < rows.length; i += 1) {
|
||||
if (rows[i].inFence) continue;
|
||||
if (rows[i].line.match(/^##\s+(.+?)\s*$/)?.[1] === section) headings.push(i);
|
||||
}
|
||||
|
||||
if (headings.length === 0) return { status: 'not-found', count: 0 };
|
||||
if (headings.length > 1) return { status: 'ambiguous', count: headings.length };
|
||||
|
||||
const [start] = headings;
|
||||
const lines = rows.map((row) => row.line);
|
||||
// 下一個段落的標題;沒有就是到結尾
|
||||
let end = lines.length;
|
||||
for (let i = start + 1; i < lines.length; i += 1) {
|
||||
if (!rows[i].inFence && /^##\s+/.test(lines[i])) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 段落與段落之間的空行屬於版面,不屬於內容:換內容時把它留著。
|
||||
// 原本就沒有空行(兩個標題緊貼)時補一個,免得新內容黏在下一個標題上。
|
||||
let tail = end;
|
||||
while (tail > start + 1 && lines[tail - 1].trim() === '') tail -= 1;
|
||||
const spacer = end === lines.length || end > tail ? lines.slice(tail, end) : [''];
|
||||
|
||||
// 換行沿用 body 原本的那一種:CRLF 的 body 裡混進 LF,會讓抽取契約交出的 raw
|
||||
// 對不上原文,之後就勾不動那幾行了
|
||||
const eol = body.includes('\r\n') ? '\r\n' : '\n';
|
||||
const normalized = content.trim().split(/\r?\n/);
|
||||
|
||||
return {
|
||||
status: 'replaced',
|
||||
body: [...lines.slice(0, start + 1), '', ...normalized, ...spacer, ...lines.slice(end)]
|
||||
.map((line) => line.replace(/\r$/, ''))
|
||||
.join(eol),
|
||||
count: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 在指定段落裡就地更新(或補上)一行「前綴+值」。
|
||||
*
|
||||
|
||||
@@ -31,23 +31,22 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
const login = resolveLogin({ host: flags.host });
|
||||
await preflight(login, repo);
|
||||
const { user } = await preflight(login, repo);
|
||||
|
||||
const issue = await fetchIssue(login, repo, index);
|
||||
const sections = parseSections(issue.body);
|
||||
@@ -62,11 +61,11 @@ main(async () => {
|
||||
目標: listSection(sections, '目標'),
|
||||
非目標: listSection(sections, '非目標'),
|
||||
名詞表: tableSection(sections, '領域名詞表'),
|
||||
流程圖: textSection(sections, '流程圖'),
|
||||
文件: textSection(sections, '文件'),
|
||||
驗收標準: listSection(sections, '驗收標準'),
|
||||
影響範圍: listSection(sections, '影響範圍'),
|
||||
未決事項: listSection(sections, '未決事項'),
|
||||
未處理留言數: await countUnmergedComments(login, repo, index),
|
||||
未處理留言數: await countUnmergedComments(login, repo, index, user.login),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
+108
-11
@@ -12,10 +12,15 @@
|
||||
* 也負責把圖解版總覽的網址寫回議題:連結以固定前綴獨佔一行,重跑時就地更新,
|
||||
* 議題原本的 markdown 白話總覽一字不動——網頁是補充,不是取代。
|
||||
*
|
||||
* 以及勾待辦:`--tick` 收抽取契約交出的那一整行 `raw`,只把它的方框換成已勾。
|
||||
* 認不出是哪一行、或那一行已經不在議題上時一律報錯,不盲改——改壞了議題的進度條會說謊,
|
||||
* 而沒有人會去比對 body 的編輯紀錄。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/issue-update.js --repo owner/name --index 12
|
||||
* [--milestone <名稱>] [--due-date YYYY-MM-DD] [--estimate-days N]
|
||||
* [--overview-url <網址>] [--host <網址>] [--dry-run]
|
||||
* [--overview-url <網址>] [--tick '<raw 那一行>' [--section 待辦]]
|
||||
* [--host <網址>] [--dry-run]
|
||||
*/
|
||||
import {
|
||||
ScriptError,
|
||||
@@ -28,7 +33,7 @@ import {
|
||||
preflight,
|
||||
resolveLogin,
|
||||
} from './lib.js';
|
||||
import { upsertLineInSection } from './issue-body.js';
|
||||
import { isListItem, tickLine, upsertLineInSection } from './issue-body.js';
|
||||
|
||||
/** artifact 預設私有,組織外開不起來——這件事要跟著連結一起留在議題上 */
|
||||
const PRIVACY_NOTE = '(此連結預設為私有,組織外無法開啟)';
|
||||
@@ -36,7 +41,7 @@ const PRIVACY_NOTE = '(此連結預設為私有,組織外無法開啟)';
|
||||
main(async () => {
|
||||
const flags = parseFlags(process.argv.slice(2), {
|
||||
required: ['repo', 'index'],
|
||||
optional: ['milestone', 'due-date', 'estimate-days', 'overview-url', 'host'],
|
||||
optional: ['milestone', 'due-date', 'estimate-days', 'overview-url', 'tick', 'section', 'host'],
|
||||
booleans: ['dry-run'],
|
||||
});
|
||||
const repo = parseRepo(flags.repo);
|
||||
@@ -44,11 +49,18 @@ main(async () => {
|
||||
const dueDate = parseDueDate(flags['due-date']);
|
||||
const days = parseDays(flags['estimate-days']);
|
||||
const overviewUrl = parseOverviewUrl(flags['overview-url']);
|
||||
const tick = parseTick(flags.tick, flags.section);
|
||||
|
||||
if (flags.milestone === undefined && dueDate === null && days === null && overviewUrl === null) {
|
||||
if (
|
||||
flags.milestone === undefined &&
|
||||
dueDate === null &&
|
||||
days === null &&
|
||||
overviewUrl === null &&
|
||||
tick === null
|
||||
) {
|
||||
throw new ScriptError(
|
||||
'NOTHING_TO_UPDATE',
|
||||
'至少要指定 --milestone、--due-date、--estimate-days 或 --overview-url 其中一個',
|
||||
'至少要指定 --milestone、--due-date、--estimate-days、--overview-url 或 --tick 其中一個',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,20 +76,38 @@ main(async () => {
|
||||
if (dueDate !== null) {
|
||||
payload.due_date = `${dueDate}T00:00:00Z`;
|
||||
}
|
||||
if (days !== null || overviewUrl !== null) {
|
||||
const issue = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`);
|
||||
let body = issue.body ?? '';
|
||||
let current = null;
|
||||
let 勾起的那一行 = null;
|
||||
let 已經勾過 = false;
|
||||
|
||||
if (days !== null || overviewUrl !== null || tick !== null) {
|
||||
current = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`);
|
||||
let body = current.body ?? '';
|
||||
|
||||
if (days !== null) body = upsertLineInSection(body, '關聯', `估算人天:${days}`);
|
||||
if (overviewUrl !== null) {
|
||||
body = upsertLineInSection(body, '總覽', `圖解版總覽:${overviewUrl}${PRIVACY_NOTE}`);
|
||||
}
|
||||
// 沒變就不塞進 PATCH:無謂改寫 body 會在議題上留下一筆沒有內容的編輯紀錄
|
||||
if (body !== issue.body) payload.body = body;
|
||||
if (tick !== null) {
|
||||
const result = applyTick(body, tick, flags.section);
|
||||
body = result.body;
|
||||
勾起的那一行 = result.line;
|
||||
已經勾過 = result.已經勾過;
|
||||
}
|
||||
if (body !== current.body) payload.body = body;
|
||||
}
|
||||
|
||||
// 全部都已經是現在這個樣子就不送:空的 PATCH 會把議題的 updated_at 推新,
|
||||
// 在列表上浮起來像是有人動過。這個判斷要做在試跑分支之前,
|
||||
// 否則試跑會預告一個實跑根本不會發的請求。
|
||||
const noop = Object.keys(payload).length === 0;
|
||||
const requests = noop ? [] : [{ method: 'PATCH', path, body: payload }];
|
||||
|
||||
if (flags['dry-run']) {
|
||||
return { dryRun: true, repo, index, requests: [{ method: 'PATCH', path, body: payload }] };
|
||||
return { dryRun: true, repo, index, 勾起的那一行, 已經勾過, requests };
|
||||
}
|
||||
if (noop) {
|
||||
return { repo, index, updated: [], 勾起的那一行, 已經勾過, url: current.html_url };
|
||||
}
|
||||
|
||||
const issue = expectOk(await giteaRequest(login, 'PATCH', path, { body: payload }), `PATCH ${path}`);
|
||||
@@ -85,11 +115,78 @@ main(async () => {
|
||||
repo,
|
||||
index,
|
||||
updated: Object.keys(payload),
|
||||
勾起的那一行,
|
||||
已經勾過,
|
||||
url: issue.html_url,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* 把 tickLine 的結果轉成這一層的錯誤碼。
|
||||
* 認不出是哪一行就報錯而不是猜——精確替換的價值全在這裡。
|
||||
*/
|
||||
function applyTick(body, raw, section) {
|
||||
const result = tickLine(body, raw, section);
|
||||
|
||||
if (result.status === 'no-section') {
|
||||
throw new ScriptError(
|
||||
'SECTION_NOT_FOUND',
|
||||
`議題上沒有「${section}」這個段落;請確認 --section 的名稱與議題上的 \`## 標題\` 完全一致`,
|
||||
);
|
||||
}
|
||||
if (result.status === 'no-checkbox') {
|
||||
throw new ScriptError(
|
||||
'NOT_A_CHECKBOX',
|
||||
`議題上這一項沒有 checkbox,沒有方框可以勾:${raw.trim()};` +
|
||||
'請先在議題上把它補成 `- [ ] …` 的寫法',
|
||||
);
|
||||
}
|
||||
if (result.status === 'not-found') {
|
||||
throw new ScriptError(
|
||||
'RAW_NOT_FOUND',
|
||||
`議題上找不到這一行:${raw.trim()};` +
|
||||
'手上的抽取結果可能已經過期(議題被改過),請重新執行 wp-extract 再試',
|
||||
);
|
||||
}
|
||||
if (result.status === 'ambiguous') {
|
||||
throw new ScriptError(
|
||||
'RAW_AMBIGUOUS',
|
||||
`這一行在議題上出現了 ${result.count} 次,分不出要勾哪一個:${raw.trim()};` +
|
||||
'請把議題上重複的那幾項改寫成看得出差別的說法,再重新抽取',
|
||||
);
|
||||
}
|
||||
return {
|
||||
body: result.status === 'ticked' ? result.body : body,
|
||||
line: result.line,
|
||||
已經勾過: result.status === 'already',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `--tick` 收的是一整行 raw,不是一段文字——精確替換的前提是它逐字等於議題上的那一行。
|
||||
* 判斷用 issue-body 導出的同一份文法:抽取端收得下的,這裡就要收得下。
|
||||
*/
|
||||
function parseTick(value, section) {
|
||||
if (value === undefined) {
|
||||
if (section !== undefined) {
|
||||
throw new ScriptError('MISSING_FLAG', '--section 是給 --tick 用的,單獨指定沒有作用');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (value.includes('\n')) {
|
||||
throw new ScriptError('BAD_RAW', '--tick 一次只勾一行,收到的內容夾帶了換行');
|
||||
}
|
||||
if (!isListItem(value)) {
|
||||
throw new ScriptError(
|
||||
'BAD_RAW',
|
||||
`--tick 需要一整行清單項(例如「- [ ] 解析九個段落」),收到的是 ${value}`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
function parseDueDate(value) {
|
||||
if (value === undefined) return null;
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
|
||||
+459
-33
@@ -1,18 +1,28 @@
|
||||
/**
|
||||
* tea-sdlc 所有腳本的共用地基。
|
||||
*
|
||||
* 這一層負責六件事,其餘腳本只寫自己的業務:
|
||||
* 這一層負責七件事,其餘腳本只寫自己的業務:
|
||||
* 1. 具名 flag 解析與單行 JSON 輸出({ok, data, error:{code, message}})
|
||||
* 2. Gitea API 呼叫 —— 全專案唯一的 HTTP 出口
|
||||
* 3. git 執行 —— 全專案唯一的子行程出口
|
||||
* 4. 四層前置檢查
|
||||
* 5. 冪等查重
|
||||
* 6. 兩支抽取腳本共用的議題讀取
|
||||
* 4. 工作樹的一生 —— 路徑推導、建立(含重建)、現況、移除
|
||||
* 5. 四層前置檢查
|
||||
* 6. 冪等查重
|
||||
* 7. 兩支抽取腳本共用的議題讀取
|
||||
*
|
||||
* 外部相依集中在 giteaRequest 與 runGit 兩個函式,測試才有地方替身。
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { accessSync, constants, existsSync, readFileSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
accessSync,
|
||||
constants,
|
||||
existsSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -20,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) {
|
||||
@@ -51,6 +61,47 @@ export function promptsDir() {
|
||||
return join(pluginRoot(), 'prompts');
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有工作樹的集中處:`~/.tea-sdlc/worktrees`。
|
||||
* 集中在一個地方,清理時只有一處要看。`TEA_SDLC_HOME` 只是測試與 CI 的覆寫出口,
|
||||
* 正常使用不必設。
|
||||
*/
|
||||
function worktreesRoot() {
|
||||
const home = process.env.TEA_SDLC_HOME?.trim() || join(homedir(), '.tea-sdlc');
|
||||
return join(home, 'worktrees');
|
||||
}
|
||||
|
||||
/**
|
||||
* 由「哪顆工作包」純函式推導出「它的工作樹在哪」。
|
||||
*
|
||||
* 不查表、不讀狀態檔:任何流程(開工、處理留言、清理)都要算得出同一條路徑,
|
||||
* 換一台機器或換一個 agent 也一樣,不存在就重建。
|
||||
*
|
||||
* 目錄名取雜湊而不是把分支名的斜線攤平成 `-`:攤平會讓 `feat/a-b/main` 與
|
||||
* `feat/a/b/main` 撞成同一個目錄,而本專案的分支命名規則恰好讓這種形狀有機會出現。
|
||||
* 可讀性的缺口由 `git worktree list` 補上——它本來就會把分支名印在路徑旁邊。
|
||||
*
|
||||
* 推導前先正規化:沒有它,同一棵工作樹會因為輸入多一個空白或大小寫不同而被推導成兩條路徑。
|
||||
*
|
||||
* @param {string} repo owner/name
|
||||
* @param {string} branch 分支名,可含斜線
|
||||
* @returns {string} ~/.tea-sdlc/worktrees/{sha256 前 12 碼}
|
||||
*/
|
||||
export function worktreePath(repo, branch) {
|
||||
const key = `${normalizeRef(repo)}/${normalizeRef(branch)}`;
|
||||
const hash = createHash('sha256').update(key).digest('hex').slice(0, 12);
|
||||
return join(worktreesRoot(), hash);
|
||||
}
|
||||
|
||||
/** 逐段修掉空白再轉小寫:`Plugins / Tea-SDLC` 與 `plugins/tea-sdlc` 是同一個東西。 */
|
||||
function normalizeRef(value) {
|
||||
return value
|
||||
.split('/')
|
||||
.map((segment) => segment.trim())
|
||||
.join('/')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
// ── 套件 manifest ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -99,10 +150,19 @@ export function parseFlags(argv, spec = {}) {
|
||||
flags[name] = true;
|
||||
continue;
|
||||
}
|
||||
if (eq === -1) i += 1;
|
||||
const value = eq === -1 ? argv[i] : arg.slice(eq + 1);
|
||||
if (eq !== -1) {
|
||||
// --key=value:等號右邊就是值,即使它本身以 -- 開頭也沒有歧義。
|
||||
// commit 訊息、PR 描述這種內容裡出現 --flag 是常態,不該因此被當成打錯 flag。
|
||||
flags[name] = arg.slice(eq + 1);
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
const value = argv[i];
|
||||
if (value === undefined || value.startsWith('--')) {
|
||||
throw new ScriptError('MISSING_FLAG', `--${name} 需要一個值`);
|
||||
throw new ScriptError(
|
||||
'MISSING_FLAG',
|
||||
`--${name} 需要一個值;值本身以 -- 開頭時請改用 --${name}=值 的寫法`,
|
||||
);
|
||||
}
|
||||
flags[name] = value;
|
||||
}
|
||||
@@ -157,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 {
|
||||
@@ -170,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';
|
||||
@@ -333,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。
|
||||
@@ -379,6 +493,274 @@ export function runGit(args, { cwd } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 要求目標專案有 origin 遠端。
|
||||
* 工作樹的起點一律取自遠端,沒有 origin 就什麼都做不了——兩支腳本擋的是同一件事,
|
||||
* 只有「為什麼需要它」那一句不同,所以那一句由呼叫端給。
|
||||
* @param {string} 用途 出現在訊息裡的理由,例如「工作樹的起點一律取自 origin/{來源分支}」
|
||||
*/
|
||||
export function requireOrigin(git, path, 用途) {
|
||||
if (!git('remote').split('\n').includes('origin')) {
|
||||
throw new ScriptError('NO_ORIGIN', `${path} 沒有 origin 遠端;${用途},請先設定 origin`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 開一個目標專案的 git repo,回傳綁在它身上的執行器。
|
||||
*
|
||||
* 碰目標專案 git 的腳本都從這裡進去:路徑不是 repo 時的錯誤碼要一致,
|
||||
* 而「把 cwd 綁進 runGit」這件事寫第三遍就該收起來了。
|
||||
*
|
||||
* @param {string} path 目標專案的根目錄
|
||||
* @returns {(...args: string[]) => string} 綁定 cwd 的 git 執行器
|
||||
*/
|
||||
export function openGitRepo(path) {
|
||||
if (!existsSync(join(path, '.git'))) {
|
||||
throw new ScriptError('NOT_A_GIT_REPO', `${path} 不是 git repo;請用 --path 指向目標專案的根目錄`);
|
||||
}
|
||||
return (...args) => runGit(args, { cwd: path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 看一棵工作樹現在是什麼狀況,並直接說出「能不能清掉、不能的話卡在哪」。
|
||||
*
|
||||
* 判斷寫在這裡而不是各呼叫端:試跑與實跑、自動與手動都要擋在同一個地方,
|
||||
* 兩份判斷遲早會分岔成「試跑說清得掉、實跑卻拒絕」。
|
||||
*
|
||||
* 那條路徑上的東西不一定是工作樹:路徑由 owner/repo/分支名 推導,不含本機 clone 的
|
||||
* 位置,所以別的 clone 也可能在同一條路徑上留下東西。
|
||||
*
|
||||
* @param {string} worktree 推導出的工作樹路徑
|
||||
* @returns {{path: string, exists: boolean, isWorktree: boolean, dirty: boolean,
|
||||
* files: string[], reason: 'missing'|'foreign'|'dirty'|'removable'}}
|
||||
*/
|
||||
export function inspectWorktree(worktree) {
|
||||
const 空的 = { path: worktree, exists: false, isWorktree: false, dirty: false, files: [] };
|
||||
if (!existsSync(worktree)) return { ...空的, reason: 'missing' };
|
||||
if (!linkedWorktree(worktree)) return { ...空的, exists: true, reason: 'foreign' };
|
||||
|
||||
const files = runGit(['status', '--porcelain'], { cwd: worktree })
|
||||
.split('\n')
|
||||
.filter((line) => line !== '')
|
||||
// 狀態欄是一到兩個字元,後面接空白才是檔名。不能固定切掉前三個字元——
|
||||
// runGit 修掉了整段輸出的前後空白,第一行的「已修改」那個前導空白也跟著沒了,
|
||||
// 切太多會讓檔名少一個字(README.md 變成 EADME.md),人照著去找會找不到。
|
||||
.map((line) => line.replace(/^\s*\S{1,2}\s+/, ''));
|
||||
|
||||
return {
|
||||
path: worktree,
|
||||
exists: true,
|
||||
isWorktree: true,
|
||||
dirty: files.length > 0,
|
||||
files,
|
||||
reason: files.length > 0 ? 'dirty' : 'removable',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 這條路徑是不是一棵「連結出去的」工作樹。
|
||||
* 認的是 `.git` 為**檔案**(裡面一行 gitdir 指回主 repo)——獨立 clone 的 `.git` 是目錄,
|
||||
* 對它下 `git worktree remove` 只會得到一句 git 的原始錯誤,而那不是使用者要的答案。
|
||||
*/
|
||||
function linkedWorktree(worktree) {
|
||||
try {
|
||||
return statSync(join(worktree, '.git')).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除一棵工作樹。自動清理與手動出口共用這一份實作,不互相開子行程。
|
||||
*
|
||||
* **絕不 `--force`。** 這件事會被 pr-watch 自動執行,而自動執行的東西只能做可逆的事:
|
||||
* 工作樹重建得回來,被刪掉的未提交變更救不回來。所以有東西沒提交時就回報擋下的原因,
|
||||
* 由呼叫端決定要報成錯誤(手動清理)還是一個待處理的建議(自動監看)。
|
||||
*
|
||||
* 只移除工作樹,**本機分支與遠端分支都保留**:本機分支不佔什麼空間,留著讓使用者
|
||||
* 還能回頭看那段歷史;遠端分支要不要刪是 Gitea 合併時的選項,由使用者自己決定。
|
||||
*
|
||||
* @param {string} worktree 推導出的工作樹路徑
|
||||
* @returns {{removed: boolean, reason: 'removed'|'missing'|'dirty'|'foreign', files: string[], path: string}}
|
||||
* reason 由 inspectWorktree 給,兩支腳本與試跑、實跑都擋在同一個判斷上
|
||||
*/
|
||||
export function removeWorktree(worktree) {
|
||||
const state = inspectWorktree(worktree);
|
||||
if (state.reason !== 'removable') return { ...state, removed: false };
|
||||
|
||||
// 在工作樹自己裡面執行:它的 .git 指得回主 repo,呼叫端因此不必知道主 clone 在哪
|
||||
runGit(['worktree', 'remove', worktree], { cwd: worktree });
|
||||
return { ...state, removed: true, reason: 'removed' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 算出要把這棵工作樹弄到手需要哪幾個 git 指令。
|
||||
*
|
||||
* 建立與**重建**共用這一份:換一台機器接手時工作樹本來就不存在,而重建若另寫一套,
|
||||
* 兩邊遲早會在「起點取自哪裡」「要不要設 upstream」這種地方分岔。
|
||||
*
|
||||
* 分成「算」與「做」兩段,`--dry-run` 才能印出真正將執行的 git 指令,
|
||||
* 而不是另外維護一份描述——兩邊分開寫就會走鐘。會擋的判斷全在這一段裡完成,
|
||||
* 所以試跑與實跑在同一個地方被擋下來。
|
||||
*
|
||||
* @param {(...args: string[]) => string} git 綁在目標專案上的 git 執行器
|
||||
* @param {{source?: string, branch: string, worktree: string}} 目標
|
||||
* 不給 `source` 就是「重建既有分支的工作樹」:分支必須已經存在,
|
||||
* 沒有「從來源長一支新的」這條路。
|
||||
* @returns {{commands: string[][], 動作: string}} commands 為空代表工作樹已經在了
|
||||
*/
|
||||
export function planWorktree(git, { source, branch, worktree }) {
|
||||
const 既有 = listWorktrees(git).find((entry) => samePath(entry.path, worktree));
|
||||
const 目錄還在 = existsSync(worktree);
|
||||
|
||||
if (既有 && 目錄還在) {
|
||||
if (既有.branch !== branch) {
|
||||
throw new ScriptError(
|
||||
'WORKTREE_PATH_TAKEN',
|
||||
`${worktree} 已經是 ${既有.branch} 的工作樹;請先 git worktree remove 它再重跑`,
|
||||
);
|
||||
}
|
||||
// 冪等:中斷重跑時接上既有那一棵,不碰裡面還沒提交的東西
|
||||
return { commands: [], 動作: '沿用既有工作樹' };
|
||||
}
|
||||
if (!既有 && 目錄還在) {
|
||||
throw new ScriptError(
|
||||
'WORKTREE_PATH_TAKEN',
|
||||
`${worktree} 已經有東西了,但它不是這個 repo 的工作樹(可能是別的 clone 留下的);` +
|
||||
'請確認裡面沒有還沒保存的東西之後移除它,再重跑',
|
||||
);
|
||||
}
|
||||
|
||||
// 起點一律取自遠端:本機同名分支可能落後好幾天,靜默拿它當起點的後果太隱蔽
|
||||
if (source !== undefined && !onRemote(git, source)) {
|
||||
throw new ScriptError(
|
||||
'SOURCE_NOT_FOUND',
|
||||
`遠端沒有來源分支 ${source};請先把它推上去(git push origin ${source}),` +
|
||||
'或改指定一個已經存在於遠端的來源分支',
|
||||
);
|
||||
}
|
||||
|
||||
// 目錄被刪掉但中繼資料還在時先清乾淨,否則 git 會說這條路徑已經註冊過
|
||||
const commands = 既有 ? [['worktree', 'prune']] : [];
|
||||
commands.push(['fetch', 'origin']);
|
||||
|
||||
if (onLocal(git, branch)) {
|
||||
// 已經有的分支接上去,不從來源蓋掉:上面可能有做到一半的進度
|
||||
commands.push(['worktree', 'add', worktree, branch]);
|
||||
return { commands, 動作: '接上本地既有' };
|
||||
}
|
||||
if (onRemote(git, branch)) {
|
||||
commands.push(['worktree', 'add', '--no-track', '-b', branch, worktree, `origin/${branch}`]);
|
||||
return { commands, 動作: '接上遠端既有' };
|
||||
}
|
||||
if (source === undefined) {
|
||||
// 重建的路走到這裡代表那一支分支已經不見了——憑空長一棵空的只會讓人以為進度還在
|
||||
throw new ScriptError(
|
||||
'BRANCH_NOT_FOUND',
|
||||
`分支 ${branch} 在本機與遠端都不存在,重建不出工作樹;` +
|
||||
'請確認分支名,或先把它推上遠端',
|
||||
);
|
||||
}
|
||||
commands.push(['worktree', 'add', '--no-track', '-b', branch, worktree, `origin/${source}`]);
|
||||
return { commands, 動作: '從來源建立' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 照計畫把工作樹建起來,失敗時回到原狀。
|
||||
* 與 `planWorktree` 成對:算歸算、做歸做,而試跑只跑前半段。
|
||||
* @param {{commands: string[][]}} plan planWorktree 的結果
|
||||
*/
|
||||
export function createWorktree(git, plan, { worktree, branch }) {
|
||||
if (plan.commands.length === 0) return;
|
||||
|
||||
// 既有的本地分支不是這次建的,回滾時不能連它一起刪掉
|
||||
const 分支本來就在 = onLocal(git, branch);
|
||||
try {
|
||||
for (const args of plan.commands) git(...args);
|
||||
} catch (error) {
|
||||
rollback(git, { worktree, branch, 保留分支: 分支本來就在 });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 這個 repo 目前有哪幾棵工作樹。
|
||||
* `--porcelain` 的輸出是以空行分隔的區塊,每塊第一行是 `worktree <路徑>`,
|
||||
* 分支則是 `branch refs/heads/<名字>`;detached 的工作樹沒有 branch 那一行。
|
||||
*/
|
||||
function listWorktrees(git) {
|
||||
return git('worktree', 'list', '--porcelain')
|
||||
.split('\n\n')
|
||||
.map((block) => {
|
||||
const path = block.match(/^worktree (.+)$/m)?.[1];
|
||||
const branch = block.match(/^branch refs\/heads\/(.+)$/m)?.[1] ?? null;
|
||||
return path ? { path, branch } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* 兩條路徑指的是不是同一個地方。
|
||||
* git 印出來的是解析過符號連結的真實路徑,而推導出來的那一條可能經過連結
|
||||
* (家目錄本身就常是一條連結),逐字比對會把同一棵工作樹判成兩棵。
|
||||
*/
|
||||
function samePath(a, b) {
|
||||
return a === b || realOrSelf(a) === realOrSelf(b);
|
||||
}
|
||||
|
||||
/** 解析得出真實路徑就用它,路徑還不存在時退回原字串 */
|
||||
function realOrSelf(path) {
|
||||
try {
|
||||
return realpathSync(path);
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 遠端有沒有這一支分支。
|
||||
*
|
||||
* 比對用全名 `refs/heads/<ref>`:`ls-remote --heads origin main` 的樣式比對吃的是
|
||||
* ref 的尾段,而本 repo 的命名慣例讓每一支分支都以 `/main` 結尾——用短名比對,
|
||||
* 拿 main 當開發分支的專案會整個誤判成「遠端已經有這一支」。
|
||||
*/
|
||||
function onRemote(git, ref) {
|
||||
return git('ls-remote', '--heads', 'origin', `refs/heads/${ref}`).trim() !== '';
|
||||
}
|
||||
|
||||
function onLocal(git, ref) {
|
||||
return git('branch', '--list', ref).trim() !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立失敗時把半成品清掉。
|
||||
*
|
||||
* `git worktree add` 失敗時仍會把新分支留下來,而那是最難查的半成品:下一次重跑會走到
|
||||
* 「目標分支已存在」那條路,起點從此不再是遠端的來源分支。本來就存在的分支不能碰——
|
||||
* 上面可能有別人的進度。
|
||||
*/
|
||||
function rollback(git, { worktree, branch, 保留分支 }) {
|
||||
quietly(git, ['worktree', 'remove', '--force', worktree]);
|
||||
quietly(git, ['worktree', 'prune']);
|
||||
if (!保留分支) quietly(git, ['branch', '-D', branch]);
|
||||
// git 清不乾淨時把目錄本身也清掉:這條路徑在這次執行之前不存在(不存在是建立的前提),
|
||||
// 裡面不可能有使用者的東西;留著它下一次重跑會直接撞上 WORKTREE_PATH_TAKEN
|
||||
try {
|
||||
rmSync(worktree, { recursive: true, force: true });
|
||||
} catch {
|
||||
// 連目錄都刪不掉就只能留著:原本的錯誤比清理的錯誤重要
|
||||
}
|
||||
}
|
||||
|
||||
/** 清理用的 git:失敗了也不能蓋掉真正的錯誤訊息,那才是使用者要看的東西。 */
|
||||
function quietly(git, args) {
|
||||
try {
|
||||
git(...args);
|
||||
} catch {
|
||||
// 清不掉就算了:原本的錯誤比清理的錯誤重要
|
||||
}
|
||||
}
|
||||
|
||||
// ── 四層前置檢查 ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -394,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 };
|
||||
}
|
||||
|
||||
@@ -514,14 +895,27 @@ 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 指定的文字檔。
|
||||
*
|
||||
* 「讀一個 --xxx-file 或直接失敗」原本在四支腳本裡各寫一份,錯誤碼還有三種拼法。
|
||||
* 同一種情況要有同一個碼,呼叫端才分辨得出到底是哪一步壞了。
|
||||
*
|
||||
* @param {string} path 檔案路徑
|
||||
* @param {string} flag 出現在錯誤訊息裡的 flag 名,例如 '--body-file'
|
||||
* @param {{allowEmpty?: boolean}} options 內容可不可以是空的;預設不可以
|
||||
* @returns {string}
|
||||
*/
|
||||
export function readTextFile(path, flag, { allowEmpty = false } = {}) {
|
||||
if (!existsSync(path)) {
|
||||
throw new ScriptError('FILE_NOT_FOUND', `找不到 ${flag} 指定的檔案 ${path}`);
|
||||
}
|
||||
const content = readFileSync(path, 'utf8');
|
||||
if (!allowEmpty && content.trim() === '') {
|
||||
throw new ScriptError('FILE_EMPTY', `${flag} 指定的檔案 ${path} 是空的`);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
// ── 議題讀取:兩支抽取腳本共用 ────────────────────────────────────
|
||||
@@ -558,32 +952,64 @@ export const UNMERGED_COMMENT_NOTE =
|
||||
* 數出尚未被整併回描述的留言則數。
|
||||
*
|
||||
* 抽取契約只讀 body 不讀留言,這個數字是下游判斷「手上的描述是不是過期了」的唯一依據。
|
||||
* 已整併的留言會被打上 `+1` reaction(由 sdlc-sync 負責標記),而 Gitea 的留言物件
|
||||
* 不含 reaction,所以只能逐則再查一次。留言多時請求數會跟著長,但這個數字要準
|
||||
* ——它決定下游會不會拿著過期的描述做事,所以留言也要逐頁讀完,讀不完寧可報錯。
|
||||
* 已整併的留言會被打上 `+1` reaction(由 sdlc-sync 負責標記)。留言多時請求數會跟著長,
|
||||
* 但這個數字要準——它決定下游會不會拿著過期的描述做事,所以留言也要逐頁讀完,
|
||||
* 讀不完寧可報錯。
|
||||
*
|
||||
* @param {{base: string, token: string}} login
|
||||
* @param {string} repo owner/name
|
||||
* @param {number} index
|
||||
* @param {string} me 目前登入帳號:只有自己打的 `+1` 才算整併過
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export async function countUnmergedComments(login, repo, index) {
|
||||
const commentsPath = `/repos/${repo}/issues/${index}/comments`;
|
||||
export async function countUnmergedComments(login, repo, index, me) {
|
||||
let unmerged = 0;
|
||||
|
||||
for await (const comments of pages(login, commentsPath, {
|
||||
limitCode: 'COMMENT_LIMIT',
|
||||
limitHint: `${commentsPath} 的留言太多,數不完未整併的則數`,
|
||||
})) {
|
||||
for (const comment of comments) {
|
||||
const path = `/repos/${repo}/issues/comments/${comment.id}/reactions`;
|
||||
const reactions = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? [];
|
||||
if (!reactions.some((reaction) => reaction.content === '+1')) unmerged += 1;
|
||||
}
|
||||
for await (const comment of listIssueComments(login, repo, index)) {
|
||||
if (!(await mergedByMe(login, repo, comment.id, me))) unmerged += 1;
|
||||
}
|
||||
return unmerged;
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐頁走過一顆議題(或 PR)的一般留言。
|
||||
* 三支腳本都要做這件事:數未整併的則數、列出留言內容、核對 --merged 的 id。
|
||||
* Gitea 的 comments 端點固定只回第一批,完整清單改從 timeline 取得。
|
||||
* @returns {AsyncGenerator<object>} 一則一則交出去
|
||||
*/
|
||||
export async function* listIssueComments(login, repo, index) {
|
||||
const path = `/repos/${repo}/issues/${index}/timeline`;
|
||||
const seen = new Set();
|
||||
|
||||
for await (const entries of pages(login, path, {
|
||||
limitCode: 'TIMELINE_LIMIT',
|
||||
limitHint: `${path} 的 timeline 太多,讀不完整份留言清單`,
|
||||
})) {
|
||||
for (const entry of entries) {
|
||||
if (entry.type !== 'comment' || seen.has(entry.id)) continue;
|
||||
seen.add(entry.id);
|
||||
yield entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 這一則是不是「我」標記過已整併。
|
||||
*
|
||||
* Gitea 的留言物件不含 reaction,只能逐則再查一次。認的是自己打的 `+1`:
|
||||
* 別人按讚是「我同意」,當成已整併會讓那一則的決策永遠不被收進描述——
|
||||
* 而那正是 /sdlc-sync 要解決的事。
|
||||
*
|
||||
* @param {string} me 目前登入帳號;preflight 的回傳帶得出來
|
||||
*/
|
||||
export async function mergedByMe(login, repo, id, me) {
|
||||
const path = `/repos/${repo}/issues/comments/${id}/reactions`;
|
||||
const reactions = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? [];
|
||||
|
||||
return reactions.some((reaction) => reaction.content === '+1' && reaction.user?.login === me);
|
||||
}
|
||||
|
||||
|
||||
// ── 標籤 ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 讀 PR 或議題上的留言。
|
||||
*
|
||||
* PR 有三類:一般留言、review 總評、行內留言,分散在三個端點——漏掉任何一類就會有
|
||||
* reviewer 的意見沒被處理,而那正是 `/sdlc-fix` 存在的理由。
|
||||
* 純議題只有一般留言,`/sdlc-sync` 要的就是那一份。
|
||||
*
|
||||
* **每個 PR 都是議題,但議題不一定是 PR。** 所以先讀 `/issues/{index}`(兩種都有),
|
||||
* 看它有沒有 `pull_request` 才決定要不要去翻 review;反過來先打 `/pulls/{index}`,
|
||||
* 對純議題會 404,整個流程在讀到第一則留言之前就斷了。
|
||||
*
|
||||
* 讀取本身與「已處理」的判定在 `pr-threads.js`——`pr-watch` 要數同一件事,
|
||||
* 規則寫兩份遲早會各自演化。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/pr-comments.js --repo owner/name --index 45 [--host <網址>] [--dry-run]
|
||||
*/
|
||||
import {
|
||||
fetchIssue,
|
||||
main,
|
||||
parseFlags,
|
||||
parseIndex,
|
||||
parseRepo,
|
||||
preflight,
|
||||
resolveLogin,
|
||||
} from './lib.js';
|
||||
import {
|
||||
ISSUE_OR_PULL_NOTE,
|
||||
commonRequests,
|
||||
readGeneralComments,
|
||||
readPullComments,
|
||||
unhandledCount,
|
||||
} from './pr-threads.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);
|
||||
|
||||
if (flags['dry-run']) {
|
||||
return {
|
||||
dryRun: true,
|
||||
repo,
|
||||
index,
|
||||
requests: commonRequests(repo, index),
|
||||
note: ISSUE_OR_PULL_NOTE,
|
||||
};
|
||||
}
|
||||
|
||||
const login = resolveLogin({ host: flags.host });
|
||||
const { user } = await preflight(login, repo);
|
||||
const me = user.login;
|
||||
|
||||
// 先讀議題:PR 也是議題,反過來不成立。這一步同時決定要不要去翻 review
|
||||
const issue = await fetchIssue(login, repo, index);
|
||||
const isPull = issue.pull_request != null;
|
||||
const 留言 = isPull
|
||||
? await readPullComments(login, repo, index, me)
|
||||
: await readGeneralComments(login, repo, index, me);
|
||||
|
||||
return {
|
||||
repo,
|
||||
index: issue.number,
|
||||
類型: isPull ? 'PR' : '議題',
|
||||
title: issue.title,
|
||||
url: issue.html_url,
|
||||
state: issue.state,
|
||||
留言,
|
||||
未處理數: unhandledCount(留言),
|
||||
};
|
||||
});
|
||||
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env node
|
||||
import { ScriptError, expectOk, giteaRequest, main, parseFlags, parseIndex, parseRepo, preflight, readTextFile, resolveLogin } from './lib.js';
|
||||
|
||||
const SECTIONS = ['摘要', '需求議題', '工作包議題', '變更內容', '設計重點', '解決的問題', '影響的功能', '測試結果'];
|
||||
const EMPTY_TALK = new Set(['無', '沒有', 'N/A', 'n/a', '已測試', '已測試通過', '測試通過', '正常', 'ok', 'OK']);
|
||||
|
||||
main(async () => {
|
||||
const flags = parseFlags(process.argv.slice(2), {
|
||||
required: ['repo', 'head', 'base', 'body-file', 'index'],
|
||||
optional: ['issue-repo', 'host'],
|
||||
booleans: ['dry-run'],
|
||||
});
|
||||
const repo = parseRepo(flags.repo);
|
||||
const issueRepo = parseRepo(flags['issue-repo'] ?? flags.repo);
|
||||
const index = parseIndex(flags.index);
|
||||
const body = readTextFile(flags['body-file'], '--body-file');
|
||||
checkSections(body);
|
||||
checkTestResult(body);
|
||||
const pullsPath = `/repos/${repo}/pulls`;
|
||||
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: flags.head, base: flags.base, title: flags.head, requests: [{ method: 'POST', path: pullsPath, body: payload }] };
|
||||
await preflight(login, repo);
|
||||
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 };
|
||||
});
|
||||
|
||||
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?.label === head || pull.head?.ref === head) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 回覆一則 PR 留言,並標記它已經處理過。
|
||||
*
|
||||
* 「回在 reviewer 原本那一串底下」對三類留言是三件不同的事:
|
||||
*
|
||||
* - **行內留言**:Gitea 沒有「回覆某一則行內留言」的端點。把新留言指向**同一個檔案
|
||||
* 與同一行**,它就會排在原留言底下——位置是串的識別,抓錯就變成另開一串。
|
||||
* 位置不由呼叫端給,而是拿留言 id 去查出來:手抄行號是這一段最容易錯的地方。
|
||||
* - **一般留言**:PR 的一般留言是平的,沒有串。回覆就是新增一則,並引用原文開頭,
|
||||
* 讓人看得出在回誰。
|
||||
* - **review 總評**:也是平的,回法同一般留言。標記用 `+1`,但 reaction 要打在它
|
||||
* **在 issue comment 表裡那一份**的 id 上——那份由 timeline 給,拿 review 自己的
|
||||
* id 去打會 404。對不到那一份時如實說標不了,不硬打。
|
||||
*
|
||||
* 標記排在回覆之後,而且回覆沒成功就不標記:沒回就標記等於謊稱處理過。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/pr-reply.js --repo owner/name --index 45 --comment <留言 id>
|
||||
* --kind inline|general|review --body '<回覆>'
|
||||
* [--host <網址>] [--dry-run]
|
||||
*/
|
||||
import {
|
||||
ScriptError,
|
||||
expectOk,
|
||||
giteaRequest,
|
||||
listIssueComments,
|
||||
main,
|
||||
pages,
|
||||
parseFlags,
|
||||
parseIndex,
|
||||
parseRepo,
|
||||
preflight,
|
||||
resolveLogin,
|
||||
} from './lib.js';
|
||||
|
||||
/** `--kind` 認得的三類,與 pr-comments 的「行內/一般/總評」一一對應 */
|
||||
const KINDS = ['inline', 'general', 'review'];
|
||||
|
||||
/** 還沒送出的 review:reviewer 自己都還看不到,不該回進去 */
|
||||
const DRAFT = 'PENDING';
|
||||
|
||||
/** 引用原文時保留的字數。引用是線索,不是複製一份。 */
|
||||
const QUOTE_MAX = 60;
|
||||
|
||||
main(async () => {
|
||||
const flags = parseFlags(process.argv.slice(2), {
|
||||
required: ['repo', 'index', 'comment', 'kind', 'body'],
|
||||
optional: ['host'],
|
||||
booleans: ['dry-run'],
|
||||
});
|
||||
const repo = parseRepo(flags.repo);
|
||||
const index = parseIndex(flags.index);
|
||||
const commentId = parseIndex(flags.comment, '--comment');
|
||||
const kind = parseKind(flags.kind);
|
||||
const body = parseBody(flags.body);
|
||||
const dryRun = flags['dry-run'] === true;
|
||||
|
||||
const login = resolveLogin({ host: flags.host });
|
||||
if (!dryRun) await preflight(login, repo);
|
||||
|
||||
// 位置與原文都要先查出來——試跑也查,位置錯了不該等到實跑才發現
|
||||
const plan = await planReply(login, repo, index, commentId, kind, body);
|
||||
|
||||
if (dryRun) {
|
||||
return { dryRun: true, repo, index, comment: commentId, kind, ...plan.報告, requests: plan.requests };
|
||||
}
|
||||
|
||||
for (const { method, path, body: payload } of plan.requests) {
|
||||
expectOk(await giteaRequest(login, method, path, { body: payload }), `${method} ${path}`);
|
||||
}
|
||||
return { repo, index, comment: commentId, kind, ...plan.報告 };
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* 算出要發哪幾個請求。
|
||||
* 分成「算」與「做」兩段,試跑印出的就是實跑會發的東西,不另外維護一份描述。
|
||||
*
|
||||
* 輸出的欄位形狀三類一致:用不到的欄位填 `null` 而不是讓它消失,
|
||||
* 下游才不必為了少一欄多寫一種分支。
|
||||
*/
|
||||
async function planReply(login, repo, index, commentId, kind, body) {
|
||||
if (kind === 'inline') {
|
||||
const target = await findInline(login, repo, index, commentId);
|
||||
return {
|
||||
requests: [
|
||||
{
|
||||
method: 'POST',
|
||||
path: `/repos/${repo}/pulls/${index}/reviews`,
|
||||
body: {
|
||||
// event 是 COMMENT:回覆一則意見不該順手把整個 PR 標成通過或要求變更
|
||||
event: 'COMMENT',
|
||||
// 落在原留言的那個 commit 上:PR 之後又推了新 commit 的話,
|
||||
// 同一個行號在新 commit 上指的是別的程式碼
|
||||
...(target.commit_id ? { commit_id: target.commit_id } : {}),
|
||||
comments: [{ path: target.path, ...positionOf(target), body }],
|
||||
},
|
||||
},
|
||||
{ method: 'POST', path: `/repos/${repo}/pulls/comments/${commentId}/resolve`, body: {} },
|
||||
],
|
||||
報告: {
|
||||
已標記: true,
|
||||
標記方式: 'resolve',
|
||||
位置: `${target.path}:${positionOf(target).new_position ?? positionOf(target).old_position}`,
|
||||
note: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (kind === 'general') {
|
||||
const original = await findGeneral(login, repo, index, commentId);
|
||||
return {
|
||||
requests: [
|
||||
{
|
||||
method: 'POST',
|
||||
path: `/repos/${repo}/issues/${index}/comments`,
|
||||
body: { body: `${quote(original.body)}\n\n${body}` },
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: `/repos/${repo}/issues/comments/${commentId}/reactions`,
|
||||
body: { content: '+1' },
|
||||
},
|
||||
],
|
||||
報告: { 已標記: true, 標記方式: 'reaction', 位置: null, note: null },
|
||||
};
|
||||
}
|
||||
|
||||
// review 總評:回覆走一般留言那條路,reaction 打在它的 issue comment 那一份上
|
||||
const review = await findReview(login, repo, index, commentId);
|
||||
const markId = await reviewCommentId(login, repo, index, commentId);
|
||||
const requests = [
|
||||
{
|
||||
method: 'POST',
|
||||
path: `/repos/${repo}/issues/${index}/comments`,
|
||||
body: { body: `${quote(review.body)}\n\n${body}` },
|
||||
},
|
||||
];
|
||||
if (markId !== undefined) {
|
||||
requests.push({
|
||||
method: 'POST',
|
||||
path: `/repos/${repo}/issues/comments/${markId}/reactions`,
|
||||
body: { content: '+1' },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
requests,
|
||||
報告: {
|
||||
已標記: markId !== undefined,
|
||||
標記方式: markId === undefined ? null : 'reaction',
|
||||
位置: null,
|
||||
note:
|
||||
markId === undefined
|
||||
? '在 timeline 上對不到這則總評在 issue comment 表裡的那一份,標記不上去;' +
|
||||
'回覆已經發出,記得在修正摘要裡交代它。'
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 回覆要送哪一個位置欄位。
|
||||
* Gitea 只填 `position` 與 `original_position` 其中一個:新檔那一側用前者,
|
||||
* 被刪掉的那一行用後者。送錯欄位,回覆會落到別的地方去。
|
||||
*/
|
||||
function positionOf(comment) {
|
||||
return (comment.position ?? 0) > 0
|
||||
? { new_position: comment.position }
|
||||
: { old_position: comment.original_position };
|
||||
}
|
||||
|
||||
/** 把原文引用起來,讓平的留言看得出在回誰。太長就截斷——引用是線索,不是複製一份。 */
|
||||
function quote(text) {
|
||||
const first = (text ?? '').trim().split('\n')[0];
|
||||
const shown = first.length > QUOTE_MAX ? `${first.slice(0, QUOTE_MAX)}…` : first;
|
||||
return `> ${shown}`;
|
||||
}
|
||||
|
||||
/** 三類共用的一句話:id 對不上多半是 --kind 給錯了 */
|
||||
function notFound(index, kind, id) {
|
||||
return new ScriptError(
|
||||
'COMMENT_NOT_FOUND',
|
||||
`PR ${index} 上找不到 id 為 ${id} 的${kind};` +
|
||||
'請確認 --kind 與 --comment 對得上(三類留言的 id 各自獨立),必要時重跑 pr-comments',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 從 PR 的所有 review 裡找出那一則行內留言,取得它的位置與 commit。
|
||||
* Gitea 沒有「依 id 取 review comment」的端點,只能逐個 review 翻。
|
||||
*/
|
||||
async function findInline(login, repo, index, commentId) {
|
||||
for await (const review of eachReview(login, repo, index)) {
|
||||
const path = `/repos/${repo}/pulls/${index}/reviews/${review.id}/comments`;
|
||||
for await (const comments of pages(login, path, {
|
||||
limitCode: 'REVIEW_COMMENT_LIMIT',
|
||||
limitHint: `${path} 的行內留言太多,找不完`,
|
||||
})) {
|
||||
const hit = comments.find((comment) => comment.id === commentId);
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
throw notFound(index, '行內留言', commentId);
|
||||
}
|
||||
|
||||
async function findGeneral(login, repo, index, commentId) {
|
||||
for await (const comment of listIssueComments(login, repo, index)) {
|
||||
if (comment.id === commentId) return comment;
|
||||
}
|
||||
throw notFound(index, '一般留言', commentId);
|
||||
}
|
||||
|
||||
async function findReview(login, repo, index, reviewId) {
|
||||
for await (const review of eachReview(login, repo, index)) {
|
||||
if (review.id === reviewId) return review;
|
||||
}
|
||||
throw notFound(index, 'review', reviewId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐頁走過已送出的 review。
|
||||
* PENDING 的跳過:那是 reviewer 寫到一半的草稿,他自己都還看不到,不該回進去。
|
||||
*/
|
||||
async function* eachReview(login, repo, index) {
|
||||
const path = `/repos/${repo}/pulls/${index}/reviews`;
|
||||
|
||||
for await (const reviews of pages(login, path, {
|
||||
limitCode: 'REVIEW_LIMIT',
|
||||
limitHint: `${path} 的 review 太多,找不完`,
|
||||
})) {
|
||||
for (const review of reviews) {
|
||||
if (review.state !== DRAFT) yield review;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 總評在 issue comment 表裡那一份的 id;對不到就回 undefined,讓呼叫端如實說標不了 */
|
||||
async function reviewCommentId(login, repo, index, reviewId) {
|
||||
const path = `/repos/${repo}/issues/${index}/timeline`;
|
||||
|
||||
for await (const entries of pages(login, path, {
|
||||
limitCode: 'TIMELINE_LIMIT',
|
||||
limitHint: `${path} 的項目太多,對不齊總評的 reaction`,
|
||||
})) {
|
||||
const hit = entries.find((entry) => entry.type === 'review' && entry.review_id === reviewId);
|
||||
if (hit) return hit.id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseKind(value) {
|
||||
if (!KINDS.includes(value)) {
|
||||
throw new ScriptError(
|
||||
'BAD_KIND',
|
||||
`--kind 需為 ${KINDS.join('/')} 其中一個,收到的是 ${value}`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseBody(value) {
|
||||
const body = value.trim();
|
||||
if (body === '') {
|
||||
throw new ScriptError('EMPTY_REPLY', '--body 不能是空的:回覆要說出這一則是怎麼處理的');
|
||||
}
|
||||
return body;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* 讀 PR 上的三類留言:一般留言、review 總評、行內留言。
|
||||
*
|
||||
* 兩支腳本共用這一份:`pr-comments` 把整份交給 `/sdlc-fix` 逐則處理,
|
||||
* `pr-watch` 只數還有幾則沒處理。判定「已處理」的規則只能有一份——兩邊各寫一次,
|
||||
* 遲早會一邊認自己打的 `+1`、另一邊認任何人的,而那個差異要等到有留言被靜靜跳過
|
||||
* 才會被發現。
|
||||
*
|
||||
* 「已處理」在三類上的機制不同:
|
||||
* - 一般留言、review 總評 → 自己打的 `+1` reaction(判定在 `lib.js` 的 mergedByMe,
|
||||
* 抽取契約數未整併則數時用的是同一條規則)
|
||||
* - 行內留言 → 有沒有被 resolve(只有 review comment 有 resolve 端點)
|
||||
*
|
||||
* **總評的 reaction 掛在它的 issue comment id 上,不是 review id。** Gitea 的 review
|
||||
* 總評在 issue comment 表裡也有一份,兩個 id 不同命名空間——拿 review id 去打
|
||||
* reaction 會 404。那一份的 id 由 timeline 給(`type: 'review'` 的項目帶 `review_id`)。
|
||||
*
|
||||
* reaction 要是**自己**打的才算已處理:reviewer 對留言按讚是「我同意」,不是
|
||||
* 「這則我處理過了」,把它當成已處理會讓那一則被靜靜跳過。
|
||||
*/
|
||||
import { ScriptError, expectOk, giteaRequest, listIssueComments, mergedByMe, pages } from './lib.js';
|
||||
|
||||
/** 還沒送出的 review:reviewer 自己都還看不到,不該被當成意見 */
|
||||
const DRAFT = 'PENDING';
|
||||
|
||||
/**
|
||||
* 讀齊三類留言。
|
||||
* @param {{base: string, token: string}} login
|
||||
* @param {string} repo owner/name
|
||||
* @param {number} index PR 編號
|
||||
* @param {string} me 自己的帳號,用來認「這則是我標的」
|
||||
* @returns {Promise<object[]>}
|
||||
*/
|
||||
export async function readPullComments(login, repo, index, me) {
|
||||
const pullPath = `/repos/${repo}/pulls/${index}`;
|
||||
return [
|
||||
...(await readGeneralComments(login, repo, index, me)),
|
||||
...(await readReviews(login, repo, index, pullPath, me)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 讀這三類留言會發出哪些請求。兩支腳本的 `--dry-run` 都印它——預告與實際發出的請求
|
||||
* 分開寫,加一個端點就會有一邊忘了改,而預告錯了等於沒有預告。
|
||||
* @returns {{method: string, path: string}[]}
|
||||
*/
|
||||
export function plannedRequests(repo, index) {
|
||||
const pullPath = `/repos/${repo}/pulls/${index}`;
|
||||
return [
|
||||
{ method: 'GET', path: '/user' },
|
||||
{ method: 'GET', path: pullPath },
|
||||
{ method: 'GET', path: `/repos/${repo}/issues/${index}/timeline` },
|
||||
{ method: 'GET', path: `${pullPath}/reviews` },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 不確定是議題還是 PR 時,一定會發的那兩個請求。
|
||||
*
|
||||
* `pr-comments` 收得下兩種輸入,而它在試跑階段還沒讀過議題、不知道是哪一種。
|
||||
* 與其假設是 PR 而列出五個(對純議題有三個根本不會發),不如只列一定會發的,
|
||||
* 其餘交給 note 說明。`pr-watch` 的輸入一定是 PR,繼續用 plannedRequests。
|
||||
*/
|
||||
export function commonRequests(repo, index) {
|
||||
return [
|
||||
{ method: 'GET', path: `/repos/${repo}/issues/${index}` },
|
||||
{ method: 'GET', path: `/repos/${repo}/issues/${index}/timeline` },
|
||||
];
|
||||
}
|
||||
|
||||
/** `commonRequests` 列不完的那部分:是 PR 的話還要再讀三處。 */
|
||||
export const ISSUE_OR_PULL_NOTE =
|
||||
'每則留言還會各查一次 reaction;是 PR 的話還會再讀 review 清單、每個 review 的行內留言' +
|
||||
'與 timeline。次數取決於留言數,事前無法列舉。';
|
||||
|
||||
/**
|
||||
* `plannedRequests` 列不完的那部分。與 readPullComments 同進退——說明的是它發出的請求。
|
||||
*/
|
||||
export const COMMENT_REQUEST_NOTE =
|
||||
'每則一般留言還會各查一次 reaction、每個 review 還會各查一次它的行內留言;' +
|
||||
'次數取決於留言數,事前無法列舉。';
|
||||
|
||||
/** 還沒被處理的則數。`/sdlc-fix` 要做的量,也是 `pr-watch` 的建議動作的依據。 */
|
||||
export function unhandledCount(留言) {
|
||||
return 留言.filter((comment) => !comment.已處理).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 讀一顆 PR。「不存在」與「沒有讀取權」要分得開——前者是編號打錯,後者是權限沒開。
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function fetchPull(login, repo, index) {
|
||||
const path = `/repos/${repo}/pulls/${index}`;
|
||||
const response = await giteaRequest(login, 'GET', path);
|
||||
if (response.status === 404) {
|
||||
throw new ScriptError('PULL_NOT_FOUND', `${repo} 沒有編號 ${index} 的 PR`);
|
||||
}
|
||||
if (response.status === 403) {
|
||||
throw new ScriptError('NO_READ_ACCESS', `目前的帳號沒有 ${repo} 的 PR ${index} 的讀取權`);
|
||||
}
|
||||
return expectOk(response, `GET ${path}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一般留言。PR 在 Gitea 裡也是 issue,所以走 issue 的留言端點——
|
||||
* 純議題也只有這一類,`/sdlc-sync` 要的就是它。
|
||||
* 內容是空的那些多半是狀態變更的系統紀錄(指派、改標題),不是意見。
|
||||
*/
|
||||
export async function readGeneralComments(login, repo, index, me) {
|
||||
const 留言 = [];
|
||||
|
||||
for await (const comment of listIssueComments(login, repo, index)) {
|
||||
if ((comment.body ?? '').trim() === '') continue;
|
||||
留言.push({
|
||||
id: comment.id,
|
||||
review: null,
|
||||
類型: '一般',
|
||||
作者: comment.user?.login ?? '',
|
||||
內容: comment.body,
|
||||
已處理: await mergedByMe(login, repo, comment.id, me),
|
||||
可標記: true,
|
||||
});
|
||||
}
|
||||
return 留言;
|
||||
}
|
||||
|
||||
/**
|
||||
* review 的總評與它底下的行內留言。
|
||||
*
|
||||
* 總評的 id 要用它在 issue comment 表裡的那一份(timeline 給),reaction 才打得上去;
|
||||
* 行內留言則要用 review 自己的 id 去查。兩個 id 都要,所以兩邊都讀。
|
||||
*/
|
||||
async function readReviews(login, repo, index, pullPath, me) {
|
||||
const path = `${pullPath}/reviews`;
|
||||
const commentIds = await reviewCommentIds(login, repo, index);
|
||||
const 留言 = [];
|
||||
|
||||
for await (const reviews of pages(login, path, {
|
||||
limitCode: 'REVIEW_LIMIT',
|
||||
limitHint: `${path} 的 review 太多,讀不完整份清單`,
|
||||
})) {
|
||||
for (const review of reviews) {
|
||||
if (review.state === DRAFT) continue;
|
||||
|
||||
const commentId = commentIds.get(review.id);
|
||||
if ((review.body ?? '').trim() !== '') {
|
||||
留言.push({
|
||||
id: commentId ?? review.id,
|
||||
review: review.id,
|
||||
類型: '總評',
|
||||
作者: review.user?.login ?? '',
|
||||
內容: review.body,
|
||||
// 找不到它在 issue comment 表裡的那一份就標不了——那時如實說,不要假裝可以
|
||||
已處理: commentId === undefined ? false : await mergedByMe(login, repo, commentId, me),
|
||||
可標記: commentId !== undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const commentsPath = `${path}/${review.id}/comments`;
|
||||
for await (const comments of pages(login, commentsPath, {
|
||||
limitCode: 'REVIEW_COMMENT_LIMIT',
|
||||
limitHint: `${commentsPath} 的行內留言太多,讀不完整份清單`,
|
||||
})) {
|
||||
for (const comment of comments) 留言.push(inlineComment(comment, review.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
return 留言;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一則行內留言。
|
||||
*
|
||||
* 位置分兩側:留在新檔那一側用 `position`,留在被刪掉的那一行用 `original_position`,
|
||||
* Gitea 只會填其中一個。只讀 position 的話,留在刪除行的留言會得到 undefined,
|
||||
* 回覆時位置就送錯欄位、落到別的地方去。
|
||||
*/
|
||||
function inlineComment(comment, reviewId) {
|
||||
const onNew = (comment.position ?? 0) > 0;
|
||||
return {
|
||||
id: comment.id,
|
||||
review: reviewId,
|
||||
類型: '行內',
|
||||
作者: comment.user?.login ?? '',
|
||||
內容: comment.body,
|
||||
檔案: comment.path,
|
||||
行: onNew ? comment.position : comment.original_position,
|
||||
側: onNew ? '新' : '舊',
|
||||
// 帶上 diff 片段:沒有它,agent 只看得到「這裡少了錯誤處理」而不知道哪裡
|
||||
diff: comment.diff_hunk ?? '',
|
||||
// 回覆要落在同一個 commit 上,否則 PR 之後又推了新 commit 時行號對不上
|
||||
commit: comment.commit_id ?? comment.original_commit_id ?? null,
|
||||
已處理: comment.resolver != null,
|
||||
可標記: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* review id → 它在 issue comment 表裡的那一則 id。
|
||||
* 總評的 reaction 掛在後者上,而 reviews 端點只給得出前者。
|
||||
*/
|
||||
async function reviewCommentIds(login, repo, index) {
|
||||
const path = `/repos/${repo}/issues/${index}/timeline`;
|
||||
const ids = new Map();
|
||||
|
||||
for await (const entries of pages(login, path, {
|
||||
limitCode: 'TIMELINE_LIMIT',
|
||||
limitHint: `${path} 的項目太多,對不齊總評的 reaction`,
|
||||
})) {
|
||||
for (const entry of entries) {
|
||||
if (entry.type === 'review' && entry.review_id) ids.set(entry.review_id, entry.id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 回報一顆 PR 的現況,並在它結束時清掉工作樹。
|
||||
*
|
||||
* 一次性、無狀態、冪等:問一次答一次,**不做變化偵測**。「已處理」的判定基準是留言上
|
||||
* 自己打的 `+1` 與行內留言的 resolve,那個狀態已經存在 Gitea 上,所以一份現況快照就
|
||||
* 足以回答「還有沒有事要做」——不必跟上次的結果比較,也就不必在本機留任何游標或狀態檔。
|
||||
*
|
||||
* **不是常駐程序也不是 daemon。** 「每隔多久跑一次」由呼叫端決定(cron、或 agent 工具
|
||||
* 自己的循環),本工具不長出排程器:daemon 要 pidfile,而本專案明定不在本機留狀態檔;
|
||||
* 常駐前景程序雖然不留檔,卻把「監看中」綁在一個終端機 session 上。
|
||||
*
|
||||
* **只通知,不動手。** 偵測到有未處理留言時只把建議動作放進輸出,不自動執行 `/sdlc-fix`
|
||||
* ——流程不該被模型自動觸發,而 `/sdlc-fix` 要求「不確定時詢問使用者」,非互動模式下
|
||||
* 那個詢問無處可去,agent 只能自行決定,等於把一條驗收標準做成謊言。
|
||||
*
|
||||
* 唯一會自動執行的副作用是清理工作樹,而且完全可逆(隨時能重建)。它只在 PR 已合併或
|
||||
* 已關閉時才發生;**被退回草稿時不清理**——那代表還要繼續改,這時候那棵工作樹更需要留著。
|
||||
* 有未提交變更就擋下並如實回報,絕不 `--force`。
|
||||
*
|
||||
* 建議動作是**列舉值**而不是一段文字:呼叫端要能程式化判斷,而不是去解讀句子。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/pr-watch.js --repo owner/name --index 46 [--host <網址>] [--dry-run]
|
||||
*/
|
||||
import {
|
||||
ScriptError,
|
||||
expectOk,
|
||||
giteaRequest,
|
||||
inspectWorktree,
|
||||
main,
|
||||
parseFlags,
|
||||
parseIndex,
|
||||
parseRepo,
|
||||
preflight,
|
||||
removeWorktree,
|
||||
resolveLogin,
|
||||
worktreePath,
|
||||
} from './lib.js';
|
||||
import {
|
||||
COMMENT_REQUEST_NOTE,
|
||||
fetchPull,
|
||||
plannedRequests,
|
||||
readPullComments,
|
||||
unhandledCount,
|
||||
} from './pr-threads.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 dryRun = flags['dry-run'] === true;
|
||||
|
||||
const login = resolveLogin({ host: flags.host });
|
||||
// 試跑照樣讀現況:這一支的輸出本來就是一份現況,手寫一份固定的清單等於什麼都沒回報
|
||||
if (!dryRun) await preflight(login, repo);
|
||||
|
||||
const me = expectOk(await giteaRequest(login, 'GET', '/user'), 'GET /user').login;
|
||||
const pull = await fetchPull(login, repo, index);
|
||||
const state = stateOf(pull);
|
||||
const 未處理留言數 = unhandledCount(await readPullComments(login, repo, index, me));
|
||||
|
||||
// 工作樹由 PR 自己的來源分支推導,不必另外給——同一顆工作包算出來的永遠是同一條路徑
|
||||
const branch = headBranch(pull);
|
||||
if (branch === '') {
|
||||
throw new ScriptError(
|
||||
'PULL_HEAD_MISSING',
|
||||
`PR #${index} 讀不到來源分支名,推導不出工作樹在哪;` +
|
||||
'請改用 worktree-remove --repo <owner/name> --branch <分支名> 指名要清哪一棵',
|
||||
);
|
||||
}
|
||||
const worktree = worktreePath(repo, branch);
|
||||
const terminal = state === 'merged' || state === 'closed';
|
||||
|
||||
// 終止狀態才清理。試跑只說要跑哪一行,不真的跑。
|
||||
const 清理 = terminal && !dryRun ? removeWorktree(worktree) : null;
|
||||
const 工作樹 = 清理 ?? inspectWorktree(worktree);
|
||||
const cleaned = 清理?.removed === true;
|
||||
// 試跑要預告的那一行,條件與實跑完全同一個:reason 由 lib 算,兩邊不各判一次
|
||||
const 清得掉 = 工作樹.reason === 'removable';
|
||||
|
||||
const 報告 = {
|
||||
repo,
|
||||
index: pull.number,
|
||||
title: pull.title,
|
||||
url: pull.html_url,
|
||||
branch,
|
||||
state,
|
||||
未處理留言數,
|
||||
工作樹: {
|
||||
路徑: worktree,
|
||||
// 清掉之後這幾個欄位講的是清理之前的狀況:cleaned 已經說了現在還在不在
|
||||
存在: cleaned ? false : 工作樹.exists,
|
||||
// 路徑上有東西卻不是工作樹(多半是別的 clone 留下的)時,清理不會發生也不該
|
||||
// 靜靜跳過——手動出口會給出 NOT_A_WORKTREE,這個欄位是它的前情提要
|
||||
是工作樹: 工作樹.isWorktree,
|
||||
有未提交變更: 工作樹.dirty,
|
||||
檔案: 工作樹.files,
|
||||
},
|
||||
terminal,
|
||||
cleaned,
|
||||
suggestedAction: suggest({ terminal, cleaned, 清得掉, 未處理留言數, 工作樹 }),
|
||||
};
|
||||
|
||||
if (!dryRun) return 報告;
|
||||
|
||||
return {
|
||||
dryRun: true,
|
||||
...報告,
|
||||
requests: plannedRequests(repo, index),
|
||||
note: COMMENT_REQUEST_NOTE,
|
||||
// 讀取是冪等的,試跑照樣發;會改變東西的只有這一行,所以只有它被留到這裡
|
||||
commands: terminal && 清得掉 ? [`git worktree remove ${worktree}`] : [],
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* PR 的來源分支名。
|
||||
*
|
||||
* **不能只看 `head.ref`。** PR 合併而來源分支被刪掉之後,Gitea 會把 `head.ref` 換成
|
||||
* `refs/pull/{編號}/head`,分支名退到 `head.label`——而合併正是唯一該清理工作樹的時機。
|
||||
* 拿那個 ref 去推導會算出一條根本不存在的路徑,然後回報「工作樹不在、沒事要做」:
|
||||
* 自動清理於是在真實情況下從來不會成立,而且靜悄悄的,沒有人會發現。
|
||||
*
|
||||
* fork 來的 PR 的 label 是 `owner:branch`,只取分支那一段——工作樹是以分支名推導的。
|
||||
*/
|
||||
function headBranch(pull) {
|
||||
const label = (pull.head?.label ?? '').trim();
|
||||
const 分支 = label.includes(':') ? label.slice(label.indexOf(':') + 1) : label;
|
||||
if (分支 !== '') return 分支;
|
||||
|
||||
// label 缺席時才退回 ref,而且 refs/pull/… 不是分支名,寧可說讀不到
|
||||
const ref = (pull.head?.ref ?? '').trim();
|
||||
return /^refs\/pull\//.test(ref) ? '' : ref;
|
||||
}
|
||||
|
||||
/**
|
||||
* PR 的四種狀態。
|
||||
* Gitea 的 `state` 只有 open/closed,合併與草稿各是另一個布林值——三個欄位湊成一種
|
||||
* 狀態,而處置是看那一種,不是看 `state`:merged 與 closed 都終止,draft 則要繼續監看。
|
||||
*/
|
||||
function stateOf(pull) {
|
||||
if (pull.merged === true) return 'merged';
|
||||
if (pull.state === 'closed') return 'closed';
|
||||
return pull.draft === true ? 'draft' : 'open';
|
||||
}
|
||||
|
||||
/**
|
||||
* 下一步該做什麼,固定四個值。
|
||||
*
|
||||
* 終止的 PR 只問清理這件事:有沒提交的東西卡著就 blocked-dirty(要人自己處理),
|
||||
* 清掉了或本來就不在就沒事了,其餘都還有一棵樹等著清——試跑不動手,路徑上是別的
|
||||
* clone 留下的東西也一樣,兩種都落在 cleanup,由手動出口給出確切的原因。
|
||||
*
|
||||
* 還沒終止的 PR 只問留言:有沒處理完的就建議去跑 /sdlc-fix,但只是建議。
|
||||
*/
|
||||
function suggest({ terminal, cleaned, 清得掉, 未處理留言數, 工作樹 }) {
|
||||
if (!terminal) return 未處理留言數 > 0 ? 'run-sdlc-fix' : 'nothing-to-do';
|
||||
if (工作樹.reason === 'dirty') return 'blocked-dirty';
|
||||
if (cleaned || 工作樹.reason === 'missing') return 'nothing-to-do';
|
||||
return 清得掉 || 工作樹.reason === 'foreign' ? 'cleanup' : 'nothing-to-do';
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 定位一顆工作包的工作樹,不在就重建。
|
||||
*
|
||||
* 處理 PR 留言的人不必先手動 `cd` 到正確的目錄:路徑由 `owner/repo/分支名` 純函式推導
|
||||
* (見 lib 的 `worktreePath`),問這一支就知道該在哪裡動手。
|
||||
*
|
||||
* **「不在就重建」是常態,不是防禦性程式設計。** 進度完全不寫在本機——換一台機器或
|
||||
* 換一個 agent 接手時,工作樹本來就不存在,而重建的成本就是一次 `git worktree add`。
|
||||
*
|
||||
* 重建與建立走同一套(lib 的 `planWorktree`/`createWorktree`):一樣先 `git fetch`
|
||||
* 更新遠端引用,一樣不設 upstream,分支已經存在就接上去而不是長一棵空的。兩邊各寫
|
||||
* 一份,遲早會在「起點取自哪裡」這種地方分岔,而那種分岔要等到有人的進度不見了
|
||||
* 才會被發現。
|
||||
*
|
||||
* 分支在本機與遠端都不存在時明確中止:憑空長一棵空的工作樹,只會讓人以為進度還在。
|
||||
* 推導出的路徑上是別的東西時也中止,不盲目拿來用。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/worktree-ensure.js --repo owner/name --branch <分支名>
|
||||
* [--path <目標專案>] [--dry-run]
|
||||
*/
|
||||
import {
|
||||
createWorktree,
|
||||
main,
|
||||
openGitRepo,
|
||||
parseFlags,
|
||||
parseRepo,
|
||||
planWorktree,
|
||||
requireOrigin,
|
||||
worktreePath,
|
||||
} from './lib.js';
|
||||
|
||||
main(async () => {
|
||||
const flags = parseFlags(process.argv.slice(2), {
|
||||
required: ['repo', 'branch'],
|
||||
optional: ['path'],
|
||||
booleans: ['dry-run'],
|
||||
});
|
||||
const repo = parseRepo(flags.repo);
|
||||
const branch = flags.branch;
|
||||
const path = flags.path ?? process.cwd();
|
||||
const worktree = worktreePath(repo, branch);
|
||||
|
||||
const git = openGitRepo(path);
|
||||
requireOrigin(git, path, `重建工作樹要先能讀到 origin/${branch}`);
|
||||
|
||||
// 不給 source:這裡只重建既有分支的工作樹,沒有「從來源長一支新的」那條路,
|
||||
// 那是 branch-prep 的事——在這裡憑空開一支新分支,等於把 PR 的進度扔掉
|
||||
const plan = planWorktree(git, { branch, worktree });
|
||||
const 報告 = { path, repo, branch, worktree, 動作: plan.動作 };
|
||||
|
||||
if (flags['dry-run']) {
|
||||
return { dryRun: true, ...報告, commands: plan.commands.map((args) => `git ${args.join(' ')}`) };
|
||||
}
|
||||
|
||||
createWorktree(git, plan, { worktree, branch });
|
||||
|
||||
return { ...報告, 重建: plan.commands.length > 0 };
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 手動清掉一棵工作樹。
|
||||
*
|
||||
* pr-watch 會在 PR 合併或關閉時自動清理,這一支是給那些**永遠不會被合併也不會被關閉**
|
||||
* 的 PR 用的出口——沒有它,那些工作樹只能靠使用者自己記得去刪。
|
||||
*
|
||||
* 兩條路共用 lib 的 `removeWorktree`,不互相開子行程:守門的規則只有一份,
|
||||
* 自動的那條與手動的這條不該長出兩種行為。
|
||||
*
|
||||
* 只移除工作樹,本機分支與遠端分支都留著。工作樹裡還有沒提交的東西就中止並報出路徑,
|
||||
* **絕不 `--force`**:工作樹重建得回來,被刪掉的未提交變更救不回來。
|
||||
*
|
||||
* 路徑由 `owner/repo/分支名` 推導,所以輸入是這兩個而不是一條路徑——要刪哪一棵由
|
||||
* 「哪顆工作包」決定,使用者不必自己去記 12 碼的雜湊目錄名。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/worktree-remove.js --repo owner/name --branch <分支名> [--dry-run]
|
||||
*/
|
||||
import {
|
||||
ScriptError,
|
||||
inspectWorktree,
|
||||
main,
|
||||
parseFlags,
|
||||
parseRepo,
|
||||
removeWorktree,
|
||||
worktreePath,
|
||||
} from './lib.js';
|
||||
|
||||
main(async () => {
|
||||
const flags = parseFlags(process.argv.slice(2), {
|
||||
required: ['repo', 'branch'],
|
||||
booleans: ['dry-run'],
|
||||
});
|
||||
const repo = parseRepo(flags.repo);
|
||||
const branch = flags.branch;
|
||||
const worktree = worktreePath(repo, branch);
|
||||
|
||||
// 試跑與實跑走同一條守門:試跑印得出漂亮的計畫、實跑卻被擋下來,是最難查的那種落差
|
||||
if (flags['dry-run']) {
|
||||
const state = inspectWorktree(worktree);
|
||||
checkRemovable(state, worktree);
|
||||
return {
|
||||
dryRun: true,
|
||||
repo,
|
||||
branch,
|
||||
worktree,
|
||||
已經不在: state.reason === 'missing',
|
||||
commands: state.reason === 'removable' ? [`git worktree remove ${worktree}`] : [],
|
||||
};
|
||||
}
|
||||
|
||||
const result = removeWorktree(worktree);
|
||||
checkRemovable(result, worktree);
|
||||
|
||||
return {
|
||||
repo,
|
||||
branch,
|
||||
worktree,
|
||||
removed: result.removed,
|
||||
// 本來就不在不算失敗:重跑這一支是常態,而結果一樣是「那棵樹不在了」
|
||||
已經不在: result.reason === 'missing',
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
/** 擋下來的兩種情況各有各的下一步,錯誤碼要分得開 */
|
||||
function checkRemovable(result, worktree) {
|
||||
if (result.reason === 'dirty') {
|
||||
throw new ScriptError(
|
||||
'WORKTREE_DIRTY',
|
||||
`工作樹 ${worktree} 裡還有沒提交的東西(${result.files.join('、')});` +
|
||||
'請先提交、暫存(git stash)或確認可以丟掉再自己刪除——' +
|
||||
'本工具不會加 --force,刪掉的未提交變更救不回來',
|
||||
);
|
||||
}
|
||||
if (result.reason === 'foreign') {
|
||||
throw new ScriptError(
|
||||
'NOT_A_WORKTREE',
|
||||
`${worktree} 上有東西,但它不是一棵 git 工作樹(可能是別的 clone 留下的);` +
|
||||
'請自己確認裡面沒有還沒保存的東西之後移除它',
|
||||
);
|
||||
}
|
||||
}
|
||||
+6
-26
@@ -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]
|
||||
@@ -14,9 +14,7 @@
|
||||
import {
|
||||
UNMERGED_COMMENT_NOTE,
|
||||
countUnmergedComments,
|
||||
expectOk,
|
||||
fetchIssue,
|
||||
giteaRequest,
|
||||
main,
|
||||
pages,
|
||||
parseFlags,
|
||||
@@ -29,7 +27,7 @@ import {
|
||||
checklistInSection,
|
||||
listSection,
|
||||
parseSections,
|
||||
referencedIndex,
|
||||
requirementIndex,
|
||||
tableRows,
|
||||
textSection,
|
||||
} from './issue-body.js';
|
||||
@@ -56,15 +54,14 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
const login = resolveLogin({ host: flags.host });
|
||||
await preflight(login, repo);
|
||||
const { user } = await preflight(login, repo);
|
||||
|
||||
const issue = await fetchIssue(login, repo, index);
|
||||
const sections = parseSections(issue.body);
|
||||
@@ -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,9 +83,7 @@ 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),
|
||||
未處理留言數: await countUnmergedComments(login, repo, index, user.login),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -110,18 +105,3 @@ async function fetchLinked(login, path, kind) {
|
||||
return indexes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 這顆議題上是不是有碼錶在跑。
|
||||
*
|
||||
* Gitea 只讓人讀自己的碼錶(`/user/stopwatches`),所以這個欄位的真正語意是
|
||||
* 「**我**的碼錶正跑在這顆議題上」。它用來提醒自己忘了停錶,不是用來判斷別人有沒有在做
|
||||
* ——領取鎖看的是 assignee。
|
||||
*/
|
||||
async function hasRunningStopwatch(login, repo, index) {
|
||||
const path = '/user/stopwatches';
|
||||
const watches = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? [];
|
||||
|
||||
return watches.some(
|
||||
(watch) => `${watch.repo_owner_name}/${watch.repo_name}` === repo && watch.issue_index === index,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
+250
-99
@@ -1,33 +1,52 @@
|
||||
/**
|
||||
* 備妥開工的分支。
|
||||
* 備妥開工的工作樹。
|
||||
*
|
||||
* 兩件事各自要驗:
|
||||
* 三件事各自要驗:
|
||||
* 1. **分支命名**是純字串規則,表格驅動,成本最低、回歸價值最高。
|
||||
* 規則錯了會一路帶到 PR 標題與 CI,事後改名很痛。
|
||||
* 2. **不覆蓋他人進度**。來源分支在遠端已存在時要 pull 而不是重建,
|
||||
* 目標分支已存在時要切過去而不是從來源蓋掉。這兩件事沒有真的遠端就驗不出來,
|
||||
* 所以測試在臨時 repo 上跑真的 git。
|
||||
* 2. **一律在獨立的工作樹上開工**。主工作區一個字都不該被動到——包含它未提交的變更,
|
||||
* 以及它現在停在哪一支分支上。
|
||||
* 3. **原子性與不覆蓋他人進度**。分支與工作樹是同一個動作,失敗時不留半成品;
|
||||
* 起點一律取自遠端,目標分支已存在時接上去而不是蓋掉。
|
||||
*
|
||||
* git 不做替身:在臨時 repo 上跑真的 git,以本機裸 repo 充當遠端,不需網路。
|
||||
* 工作樹則以 TEA_SDLC_HOME 改指到測試暫存,不落到開發者真正的家目錄。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { runScript, tmpRoot } from './helpers/run-script.js';
|
||||
import { makeTempRepoWithRemote } from './helpers/temp-repo.js';
|
||||
|
||||
/** 開一個有遠端的臨時 repo,並登記在測試結束時清掉 */
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
|
||||
/** 開一個有遠端的臨時 repo 與一個空的工作樹家,並登記在測試結束時清掉 */
|
||||
function withRepo(t) {
|
||||
const repo = makeTempRepoWithRemote();
|
||||
t.after(() => repo.cleanup());
|
||||
return repo;
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
const home = mkdtempSync(join(tmpRoot, 'home-'));
|
||||
t.after(() => {
|
||||
// 工作樹裡有 .git 檔指回主 repo,直接刪目錄即可;主 repo 隨後也會被刪掉
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
return { ...repo, home };
|
||||
}
|
||||
|
||||
const run = (repo, args) => runScript('branch-prep.js', ['--path', repo.dir, ...args]);
|
||||
const run = (repo, args) =>
|
||||
runScript('branch-prep.js', ['--repo', REPO, '--path', repo.dir, ...args], {
|
||||
env: { TEA_SDLC_HOME: repo.home },
|
||||
});
|
||||
|
||||
/** 目前 checkout 在哪一支 */
|
||||
/** 主工作區目前停在哪一支 */
|
||||
const currentBranch = (repo) => repo.git('rev-parse', '--abbrev-ref', 'HEAD');
|
||||
|
||||
/** 工作樹裡 checkout 出來的是哪一支 */
|
||||
const branchIn = (dir) =>
|
||||
execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim();
|
||||
|
||||
// ── 分支命名規則 ───────────────────────────────────────────────────
|
||||
|
||||
const NAMING = [
|
||||
@@ -66,13 +85,14 @@ const NAMING = [
|
||||
for (const { name, source, args, expected } of NAMING) {
|
||||
test(`命名:${name}`, async (t) => {
|
||||
const repo = withRepo(t);
|
||||
if (source !== 'master') repo.git('checkout', '-q', '-B', source);
|
||||
// 來源分支一律取自遠端,所以先讓遠端有這一支
|
||||
if (source !== 'master') repo.pushFromElsewhere(source, `${expected.replace(/\//g, '-')}.txt`, '來源\n');
|
||||
|
||||
const { code, json } = await run(repo, ['--source', source, ...args]);
|
||||
|
||||
assert.equal(code, 0, json.error?.message);
|
||||
assert.equal(json.data.branch, expected);
|
||||
assert.equal(currentBranch(repo), expected);
|
||||
assert.equal(branchIn(json.data.worktree), expected, '工作樹裡 checkout 出來的要是新分支');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -128,7 +148,7 @@ test('從開發分支長出卻沒給 --type 時,指名缺的是哪一個', asy
|
||||
|
||||
test('從功能分支長出時給 --type 會被擋,避免子分支跑到別棵樹下', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
repo.git('checkout', '-q', '-B', 'feat/wp-extract-contract/main');
|
||||
repo.pushFromElsewhere('feat/wp-extract-contract/main', 'src.txt', '來源\n');
|
||||
|
||||
const { json } = await run(repo, [
|
||||
'--source', 'feat/wp-extract-contract/main', '--type', 'fix', '--slug', 'crlf',
|
||||
@@ -138,41 +158,72 @@ test('從功能分支長出時給 --type 會被擋,避免子分支跑到別棵
|
||||
assert.match(json.error.message, /feat/);
|
||||
});
|
||||
|
||||
// ── 來源分支:pull 而不是重建 ─────────────────────────────────────
|
||||
// ── 一律建立工作樹 ─────────────────────────────────────────────────
|
||||
|
||||
test('來源分支在遠端已存在時執行 pull,帶進別人的進度', async (t) => {
|
||||
test('分支與工作樹一起建立,主工作區完全不被動到', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
|
||||
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||
|
||||
assert.equal(code, 0, json.error?.message);
|
||||
assert.equal(existsSync(json.data.worktree), true, '工作樹要真的在磁碟上');
|
||||
assert.equal(branchIn(json.data.worktree), 'feat/mine/main');
|
||||
assert.equal(currentBranch(repo), 'master', '主工作區不該被切走:agent 會在那裡讀到不屬於它的程式碼');
|
||||
});
|
||||
|
||||
test('主工作區有未提交的變更時照樣開得了工,那正是工作樹要解決的事', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
writeFileSync(join(repo.dir, 'README.md'), '改到一半的東西\n');
|
||||
writeFileSync(join(repo.dir, 'stray.txt'), '不相干的檔案\n');
|
||||
|
||||
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||
|
||||
assert.equal(code, 0, json.error?.message);
|
||||
assert.equal(
|
||||
repo.git('status', '--porcelain').includes('stray.txt'),
|
||||
true,
|
||||
'未提交的變更要原封不動留在主工作區,不被帶到新分支上',
|
||||
);
|
||||
assert.equal(existsSync(join(json.data.worktree, 'stray.txt')), false, '也不該跟到工作樹裡');
|
||||
});
|
||||
|
||||
test('工作樹路徑在集中的家底下,不長在目標專案裡也不長在它的兄弟目錄', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
|
||||
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||
|
||||
assert.equal(json.data.worktree.startsWith(join(repo.home, 'worktrees')), true);
|
||||
assert.equal(repo.git('status', '--porcelain'), '', '工作樹不該出現在目標專案的 git status 裡');
|
||||
});
|
||||
|
||||
// ── 起點一律取自遠端 ───────────────────────────────────────────────
|
||||
|
||||
test('本機落後時,工作樹仍從遠端的最新狀態長出', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
repo.pushFromElsewhere('master', 'theirs.txt', '別人的進度\n');
|
||||
|
||||
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||
|
||||
assert.equal(code, 0, json.error?.message);
|
||||
assert.ok(existsSync(join(repo.dir, 'theirs.txt')), '別人推上去的檔案要被帶進來');
|
||||
assert.equal(json.data.來源.動作, 'pull');
|
||||
assert.equal(existsSync(join(json.data.worktree, 'theirs.txt')), true, '起點要是遠端的最新狀態');
|
||||
assert.equal(existsSync(join(repo.dir, 'theirs.txt')), false, '主工作區不必被順便更新');
|
||||
});
|
||||
|
||||
test('來源分支只在本地時照樣可用,不會因為遠端沒有就報錯', async (t) => {
|
||||
test('遠端沒有來源分支時明確中止,不退回本機同名分支', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
repo.git('checkout', '-q', '-B', 'feat/local-only/main');
|
||||
repo.git('checkout', '-q', 'master');
|
||||
repo.git('branch', 'feat/local-only/main');
|
||||
|
||||
const { code, json } = await run(repo, [
|
||||
'--source', 'feat/local-only/main', '--slug', 'sub-feature',
|
||||
]);
|
||||
|
||||
assert.equal(code, 0, json.error?.message);
|
||||
assert.equal(json.data.來源.動作, '用本地既有');
|
||||
});
|
||||
|
||||
test('來源分支本地與遠端都沒有時,回可區分的錯誤碼', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
|
||||
const { code, json } = await run(repo, [
|
||||
'--source', 'feat/不存在/main', '--slug', 'whatever',
|
||||
]);
|
||||
const { code, json } = await run(repo, ['--source', 'feat/local-only/main', '--slug', 'sub-feature']);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'SOURCE_NOT_FOUND');
|
||||
assert.match(json.error.message, /推/, '要指出下一步是把來源分支推上去');
|
||||
assert.match(json.error.message, /來源/, '或改指定一個已存在的來源分支');
|
||||
assert.equal(
|
||||
repo.git('branch', '--list', 'feat/local-only/sub-feature'),
|
||||
'',
|
||||
'擋下來就不該已經建好分支',
|
||||
);
|
||||
});
|
||||
|
||||
test('遠端分支的比對是全名,不是尾段', async (t) => {
|
||||
@@ -188,95 +239,151 @@ test('遠端分支的比對是全名,不是尾段', async (t) => {
|
||||
assert.equal(json.error.code, 'SOURCE_NOT_FOUND', '遠端沒有 main 這一支,不該被 feat/x/main 冒名頂替');
|
||||
});
|
||||
|
||||
// ── 開工前的工作區必須乾淨 ─────────────────────────────────────────
|
||||
|
||||
test('工作區有未提交的改動時擋下,不把它們帶進新分支', async (t) => {
|
||||
test('不為新分支設定 upstream:此刻遠端還沒有這一支,設了會誤指到來源分支', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
writeFileSync(join(repo.dir, 'README.md'), '改到一半的東西\n');
|
||||
|
||||
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'DIRTY_WORKTREE');
|
||||
assert.equal(currentBranch(repo), 'master', '擋下來就不該已經切過分支');
|
||||
assert.equal(repo.git('branch', '--list', 'feat/mine/main'), '', '也不該已經建好分支');
|
||||
});
|
||||
|
||||
test('未追蹤的檔案同樣算不乾淨:它會跟著被帶到新分支上', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
writeFileSync(join(repo.dir, 'stray.txt'), '不相干的檔案\n');
|
||||
|
||||
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||
|
||||
assert.equal(json.error.code, 'DIRTY_WORKTREE');
|
||||
assert.match(json.error.message, /stray\.txt/, '要指名是哪些檔案擋住了');
|
||||
assert.equal(
|
||||
repo.git('for-each-ref', '--format=%(upstream)', 'refs/heads/feat/mine/main'),
|
||||
'',
|
||||
'upstream 指到來源分支的話,之後 git pull 會把來源分支的提交拉進來',
|
||||
);
|
||||
assert.equal(json.data.branch, 'feat/mine/main');
|
||||
});
|
||||
|
||||
test('工作區不乾淨時,--dry-run 也要照樣說出來', async (t) => {
|
||||
// 試跑印得出漂亮的計畫、實跑卻中途炸掉,是最難查的那種落差
|
||||
// ── 原子性:失敗不留半成品 ─────────────────────────────────────────
|
||||
|
||||
test('工作樹建不起來時,不留下那一支已經建好的分支', async (t) => {
|
||||
// git worktree add 失敗時仍會把分支留下來,那是最難查的半成品:
|
||||
// 下一次重跑會走到「目標分支已存在」那條路,起點從此不是遠端的來源分支
|
||||
const repo = withRepo(t);
|
||||
writeFileSync(join(repo.dir, 'stray.txt'), '不相干的檔案\n');
|
||||
const blocked = join(repo.home, 'blocker');
|
||||
writeFileSync(blocked, '這是一個檔案,不是目錄\n');
|
||||
|
||||
const { json } = await run(repo, [
|
||||
'--source', 'master', '--type', 'feat', '--slug', 'mine', '--dry-run',
|
||||
]);
|
||||
|
||||
assert.equal(json.error.code, 'DIRTY_WORKTREE');
|
||||
});
|
||||
|
||||
// ── 來源分支與遠端分歧 ─────────────────────────────────────────────
|
||||
|
||||
test('本地來源分支與遠端分歧時,回可區分的錯誤碼而不是 git 的原始訊息', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
// 本地有一顆沒推的 commit,遠端也往前走了一顆
|
||||
writeFileSync(join(repo.dir, 'mine.txt'), '我的\n');
|
||||
repo.git('add', '-A');
|
||||
repo.git('commit', '-qm', '本地未推的 commit');
|
||||
repo.pushFromElsewhere('master', 'theirs.txt', '別人的\n');
|
||||
|
||||
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||
const { code, json } = await runScript(
|
||||
'branch-prep.js',
|
||||
['--repo', REPO, '--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', 'mine'],
|
||||
{ env: { TEA_SDLC_HOME: blocked } },
|
||||
);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'SOURCE_DIVERGED');
|
||||
assert.match(json.error.message, /master/);
|
||||
assert.equal(currentBranch(repo), 'master', '不該留在半途的狀態');
|
||||
assert.equal(json.error.code, 'GIT_FAILED');
|
||||
assert.equal(repo.git('branch', '--list', 'feat/mine/main'), '', '分支不該留下來');
|
||||
assert.equal(
|
||||
repo.git('worktree', 'list', '--porcelain').includes('feat/mine/main'),
|
||||
false,
|
||||
'也不該留下工作樹的中繼資料',
|
||||
);
|
||||
assert.equal(existsSync(join(blocked, 'worktrees')), false, '半途建出來的目錄也不該留下來');
|
||||
});
|
||||
|
||||
// ── 目標分支:已存在就切過去,不覆蓋 ───────────────────────────────
|
||||
// ── 目標分支已存在:接上去,不覆蓋 ─────────────────────────────────
|
||||
|
||||
test('目標分支已在遠端時切過去,保留上面已有的進度', async (t) => {
|
||||
test('目標分支已在遠端時接上去,保留上面已有的進度', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
repo.pushFromElsewhere('feat/mine/main', 'progress.txt', '已經做了一半\n');
|
||||
|
||||
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||
|
||||
assert.equal(code, 0, json.error?.message);
|
||||
assert.equal(currentBranch(repo), 'feat/mine/main');
|
||||
assert.ok(
|
||||
existsSync(join(repo.dir, 'progress.txt')),
|
||||
assert.equal(json.data.分支.動作, '接上遠端既有');
|
||||
assert.equal(
|
||||
existsSync(join(json.data.worktree, 'progress.txt')),
|
||||
true,
|
||||
'遠端已有的分支要接上去,不是從來源重建一個空的蓋掉',
|
||||
);
|
||||
assert.equal(json.data.分支.動作, '接上遠端既有');
|
||||
});
|
||||
|
||||
test('目標分支只在本地時切過去,不重建', async (t) => {
|
||||
test('目標分支只在本地時接上去,不重建', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
repo.git('checkout', '-q', '-b', 'feat/mine/main');
|
||||
repo.git('checkout', '-q', 'master');
|
||||
repo.git('branch', 'feat/mine/main');
|
||||
|
||||
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||
|
||||
assert.equal(json.data.分支.動作, '切換到本地既有');
|
||||
assert.equal(currentBranch(repo), 'feat/mine/main');
|
||||
assert.equal(json.data.分支.動作, '接上本地既有');
|
||||
assert.equal(branchIn(json.data.worktree), 'feat/mine/main');
|
||||
});
|
||||
|
||||
test('目標分支不存在時從來源建立', async (t) => {
|
||||
test('目標分支不存在時從遠端的來源分支建立', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
|
||||
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'brand-new']);
|
||||
|
||||
assert.equal(json.data.分支.動作, '從來源建立');
|
||||
assert.equal(currentBranch(repo), 'feat/brand-new/main');
|
||||
});
|
||||
|
||||
// ── 冪等:工作樹已經在了 ───────────────────────────────────────────
|
||||
|
||||
test('工作樹已經在了就沿用,不動裡面還沒提交的東西', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
const first = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||
writeFileSync(join(first.json.data.worktree, 'wip.txt'), '做到一半\n');
|
||||
|
||||
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||
|
||||
assert.equal(code, 0, json.error?.message);
|
||||
assert.equal(json.data.worktree, first.json.data.worktree, '推導出來的是同一條路徑');
|
||||
assert.equal(json.data.分支.動作, '沿用既有工作樹');
|
||||
assert.equal(existsSync(join(json.data.worktree, 'wip.txt')), true, '重跑不該把做到一半的東西刷掉');
|
||||
});
|
||||
|
||||
test('推導出來的路徑被別的東西佔住時明確中止,不硬蓋過去', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
const { json: first } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||
// 造出「別的 clone 留下的目錄」:本 repo 的 git 已經不認得它了,但路徑還在
|
||||
repo.git('worktree', 'remove', '--force', first.data.worktree);
|
||||
mkdirSync(first.data.worktree, { recursive: true });
|
||||
writeFileSync(join(first.data.worktree, '別人的東西.txt'), 'x\n');
|
||||
|
||||
const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'WORKTREE_PATH_TAKEN');
|
||||
assert.match(json.error.message, new RegExp(first.data.worktree), '要指名是哪一條路徑被佔住');
|
||||
assert.equal(existsSync(join(first.data.worktree, '別人的東西.txt')), true, '不得動到裡面的東西');
|
||||
});
|
||||
|
||||
// ── 乾淨的工作樹 ───────────────────────────────────────────────────
|
||||
|
||||
test('建好後說明這是一棵乾淨的工作樹,並依專案檔給出安裝指令', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
repo.pushFromElsewhere('master', 'package.json', '{"name":"demo"}\n');
|
||||
|
||||
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||
|
||||
assert.deepEqual(json.data.提示.安裝指令, ['npm install']);
|
||||
assert.match(json.data.提示.訊息, /乾淨/);
|
||||
});
|
||||
|
||||
test('偵測不到專案檔時不亂猜指令,只說明它是乾淨的', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
|
||||
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||
|
||||
assert.deepEqual(json.data.提示.安裝指令, []);
|
||||
assert.match(json.data.提示.訊息, /乾淨/);
|
||||
});
|
||||
|
||||
test('多種語言的專案檔都偵測得到,各給各的指令', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
repo.pushFromElsewhere('master', 'composer.json', '{}\n');
|
||||
|
||||
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||
|
||||
assert.deepEqual(json.data.提示.安裝指令, ['composer install']);
|
||||
});
|
||||
|
||||
test('不複製也不連結依賴、建置產物與機密檔案', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
mkdirSync(join(repo.dir, 'node_modules', 'left-pad'), { recursive: true });
|
||||
writeFileSync(join(repo.dir, 'node_modules', 'left-pad', 'index.js'), '\n');
|
||||
writeFileSync(join(repo.dir, '.env'), 'TOKEN=秘密\n');
|
||||
|
||||
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||
|
||||
assert.equal(existsSync(join(json.data.worktree, 'node_modules')), false, 'symlink 會把隔離接回去');
|
||||
assert.equal(existsSync(join(json.data.worktree, '.env')), false, '機密一律由使用者自己放');
|
||||
});
|
||||
|
||||
// ── 不留本機狀態檔 ─────────────────────────────────────────────────
|
||||
@@ -286,12 +393,11 @@ test('跑完不在目標專案裡留下任何狀態檔', async (t) => {
|
||||
|
||||
await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'no-state']);
|
||||
|
||||
const status = repo.git('status', '--porcelain');
|
||||
assert.equal(status, '', '工作區要是乾淨的:進度只從 Gitea 推導,不寫本機狀態檔');
|
||||
assert.equal(repo.git('status', '--porcelain'), '', '進度只從 Gitea 與 git 本身推導,不寫本機狀態檔');
|
||||
});
|
||||
|
||||
test('git 自己的進度訊息不漏到 stderr', async (t) => {
|
||||
// checkout 與 fetch 的訊息 git 一律寫在 stderr。腳本的輸出契約是「stdout 一行 JSON、
|
||||
// fetch 與 worktree add 的訊息 git 一律寫在 stderr。腳本的輸出契約是「stdout 一行 JSON、
|
||||
// stderr 乾淨」,漏出去的話呼叫端就得去分辨哪幾行是雜訊。
|
||||
const repo = withRepo(t);
|
||||
|
||||
@@ -300,22 +406,43 @@ test('git 自己的進度訊息不漏到 stderr', async (t) => {
|
||||
assert.equal(stderr, '');
|
||||
});
|
||||
|
||||
// ── 路徑 ───────────────────────────────────────────────────────────
|
||||
// ── 路徑與 flag ───────────────────────────────────────────────────
|
||||
|
||||
test('--path 指向的不是 git repo 時,回可區分的錯誤碼', async (t) => {
|
||||
const { json } = await runScript('branch-prep.js', [
|
||||
'--path', tmpRoot, '--source', 'master', '--type', 'feat', '--slug', 'whatever',
|
||||
'--repo', REPO, '--path', tmpRoot, '--source', 'master', '--type', 'feat', '--slug', 'whatever',
|
||||
]);
|
||||
|
||||
assert.equal(json.error.code, 'NOT_A_GIT_REPO');
|
||||
});
|
||||
|
||||
test('缺 --repo 時擋下:沒有它推導不出工作樹在哪', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
|
||||
const { json } = await runScript('branch-prep.js', [
|
||||
'--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', 'mine',
|
||||
], { env: { TEA_SDLC_HOME: repo.home } });
|
||||
|
||||
assert.equal(json.error.code, 'MISSING_FLAG');
|
||||
assert.match(json.error.message, /--repo/);
|
||||
});
|
||||
|
||||
test('目標專案沒有 origin 時擋下:起點一律取自遠端', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
repo.git('remote', 'remove', 'origin');
|
||||
|
||||
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||
|
||||
assert.equal(json.error.code, 'NO_ORIGIN');
|
||||
});
|
||||
|
||||
test('回報實際動到的是哪一個目錄,讓人確認沒搞錯專案', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
|
||||
const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']);
|
||||
|
||||
assert.equal(json.data.path, repo.dir);
|
||||
assert.equal(json.data.repo, REPO);
|
||||
});
|
||||
|
||||
// ── --dry-run ─────────────────────────────────────────────────────
|
||||
@@ -331,23 +458,47 @@ test('--dry-run 印出將執行的 git 指令,且完全不動 repo', async (t)
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.dryRun, true);
|
||||
assert.equal(json.data.branch, 'feat/mine/main');
|
||||
assert.ok(json.data.commands.length > 0);
|
||||
assert.ok(
|
||||
json.data.commands.every((c) => c.startsWith('git ')),
|
||||
'預覽的是 git 指令本身,不是自創的描述',
|
||||
);
|
||||
assert.equal(currentBranch(repo), 'master', '試跑不該切分支');
|
||||
assert.equal(repo.git('rev-parse', 'HEAD'), before);
|
||||
assert.equal(repo.git('branch', '--list', 'feat/mine/main'), '', '試跑不該建分支');
|
||||
assert.equal(existsSync(json.data.worktree), false, '試跑不該建工作樹');
|
||||
});
|
||||
|
||||
test('--dry-run 連分支命名都先算出來,看得到才叫預覽', async (t) => {
|
||||
test('--dry-run 把 fetch 與建立工作樹兩步都印出來,順序不顛倒', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
repo.git('checkout', '-q', '-B', 'feat/wp-extract-contract/main');
|
||||
|
||||
const { json } = await run(repo, [
|
||||
'--source', 'master', '--type', 'feat', '--slug', 'mine', '--dry-run',
|
||||
]);
|
||||
|
||||
assert.deepEqual(json.data.commands, [
|
||||
'git fetch origin',
|
||||
`git worktree add --no-track -b feat/mine/main ${json.data.worktree} origin/master`,
|
||||
], '建分支與建工作樹是同一個指令,不拆成兩步');
|
||||
});
|
||||
|
||||
test('--dry-run 連工作樹路徑都先算出來,看得到才叫預覽', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
repo.pushFromElsewhere('feat/wp-extract-contract/main', 'src.txt', '來源\n');
|
||||
|
||||
const { json } = await run(repo, [
|
||||
'--source', 'feat/wp-extract-contract/main', '--slug', 'crlf-fix', '--dry-run',
|
||||
]);
|
||||
|
||||
assert.equal(json.data.branch, 'feat/wp-extract-contract/crlf-fix');
|
||||
assert.match(json.data.worktree, /worktrees\/[0-9a-f]{12}$/);
|
||||
});
|
||||
|
||||
test('遠端沒有來源分支時,--dry-run 也要照樣說出來', async (t) => {
|
||||
// 試跑印得出漂亮的計畫、實跑卻中途炸掉,是最難查的那種落差
|
||||
const repo = withRepo(t);
|
||||
|
||||
const { json } = await run(repo, [
|
||||
'--source', 'feat/沒有這支/main', '--slug', 'mine', '--dry-run',
|
||||
]);
|
||||
|
||||
assert.equal(json.error.code, 'SOURCE_NOT_FOUND');
|
||||
});
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
/**
|
||||
* 領取工作包的鎖。
|
||||
*
|
||||
* 這一支的價值全在「什麼時候擋下來」:放行的路徑只有一條,擋的理由有四種,
|
||||
* 而擋錯的代價是兩個人做同一件事、或是工時記到別顆議題上。所以決策表的四種狀態
|
||||
* 各有測試,而且每一種都要驗「一個字都沒寫進 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.碼錶中, true);
|
||||
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`,
|
||||
`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/start`,
|
||||
],
|
||||
'錶要最後才起:前面任一步失敗時,不該留下一顆還在跑的碼錶',
|
||||
);
|
||||
});
|
||||
|
||||
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.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.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.碼錶中, true, '錶還是要起,中斷重跑就是為了接上這件事');
|
||||
});
|
||||
|
||||
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`,
|
||||
`POST /repos/${REPO}/issues/${INDEX}/stopwatch/start`,
|
||||
],
|
||||
);
|
||||
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.map((r) => `${r.method} ${r.path}`),
|
||||
[`POST /repos/${REPO}/issues/${INDEX}/stopwatch/start`],
|
||||
'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');
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 實作規範與註解格式對照表這兩份規則正本。
|
||||
*
|
||||
* 它們是 /sdlc-feat 第二段實際交付的東西:規範寫漏一條,產出的程式碼就少一種註解,
|
||||
* 而那要等 reviewer 看到才會發現。對照表少一種語言,agent 就會開始猜格式。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readReference } from './helpers/prompt-doc.js';
|
||||
|
||||
const standards = readReference('coding-standards');
|
||||
const styles = readReference('comment-styles');
|
||||
|
||||
/**
|
||||
* 切出一個 `## 標題` 段落。
|
||||
* 以整行比對而不是 indexOf:`## Java` 是 `## JavaScript` 的前綴,
|
||||
* 用 indexOf 會切到錯的那一節,而且切出來還是有內容的,錯得很安靜。
|
||||
*/
|
||||
function sectionOf(doc, heading) {
|
||||
const lines = doc.split('\n');
|
||||
const start = lines.findIndex((line) => line.trim() === `## ${heading}`);
|
||||
if (start === -1) return null;
|
||||
|
||||
const rest = lines.slice(start + 1);
|
||||
const end = rest.findIndex((line) => line.startsWith('## '));
|
||||
return (end === -1 ? rest : rest.slice(0, end)).join('\n');
|
||||
}
|
||||
|
||||
// ── 實作規範 ───────────────────────────────────────────────────────
|
||||
|
||||
test('六種專案檔都對得到語言', () => {
|
||||
for (const file of [
|
||||
'\\*\\.csproj',
|
||||
'composer\\.json',
|
||||
'package\\.json',
|
||||
'go\\.mod',
|
||||
'pom\\.xml',
|
||||
'pyproject\\.toml',
|
||||
]) {
|
||||
assert.match(standards, new RegExp(file), `專案檔對照缺少 ${file}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('認不出語言時要停下來問,而且說明了為什麼不猜', () => {
|
||||
assert.match(standards, /認不出來就停下來問/);
|
||||
assert.match(standards, /不要猜/);
|
||||
assert.match(standards, /比沒有註解更難清理/, '要說出猜錯的代價,否則這條規則會被當成客套話');
|
||||
});
|
||||
|
||||
test('分層判定明講看職責不看目錄', () => {
|
||||
assert.match(standards, /看職責,不看目錄/);
|
||||
assert.match(standards, /目錄名稱會騙人/);
|
||||
});
|
||||
|
||||
test('三層各自要寫哪一種註解都寫明了', () => {
|
||||
for (const [layer, comment] of [
|
||||
['控制層', '功能註解'],
|
||||
['服務層', '邏輯註解'],
|
||||
['存取層', '資料源註解'],
|
||||
]) {
|
||||
const row = standards.split('\n').find((line) => line.includes(layer) && line.includes('|'));
|
||||
assert.ok(row, `${layer}沒有出現在分層表裡`);
|
||||
assert.match(row, new RegExp(comment), `${layer}要寫的是${comment}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('服務層要標註呼叫的方法,並說明理由是追呼叫鏈', () => {
|
||||
assert.match(standards, /標註它呼叫的所有方法/);
|
||||
assert.match(standards, /追得到呼叫鏈/);
|
||||
});
|
||||
|
||||
test('屬性註解要遞迴,而且明講不能只註解最外層', () => {
|
||||
assert.match(standards, /屬性本身是類別時遞迴處理/);
|
||||
assert.match(standards, /不能只註解最外層/);
|
||||
});
|
||||
|
||||
test('資料範例的來源有優先序,且未經驗證時要註明', () => {
|
||||
assert.match(standards, /優先從 MCP 取得/);
|
||||
assert.match(standards, /由邏輯推理、未經驗證/);
|
||||
assert.match(standards, /有人會照著那個格式寫解析/, '要說出不註明的代價');
|
||||
});
|
||||
|
||||
test('明講不寫入目標專案的任何檔案', () => {
|
||||
assert.match(standards, /不寫入目標專案的任何檔案/);
|
||||
assert.match(standards, /CLAUDE\.md/);
|
||||
});
|
||||
|
||||
// ── 註解格式對照表 ─────────────────────────────────────────────────
|
||||
|
||||
test('六種語言各有一節,且都附可照抄的程式碼範例', () => {
|
||||
for (const [language, marker] of [
|
||||
['C#', '///'],
|
||||
['PHP', '@var'],
|
||||
['JavaScript/TypeScript', 'JSDoc'],
|
||||
['Go', 'go doc'],
|
||||
['Java', 'Javadoc'],
|
||||
['Python', 'docstring'],
|
||||
]) {
|
||||
const body = sectionOf(styles, language);
|
||||
assert.ok(body, `對照表缺少 ${language}`);
|
||||
assert.match(body, new RegExp(marker.replace(/[/#]/g, '\\$&')), `${language} 缺少 ${marker}`);
|
||||
assert.match(body, /```/, `${language} 要有可照抄的範例,不要只用文字描述`);
|
||||
}
|
||||
});
|
||||
|
||||
test('每個語言的範例都同時示範了方法註解與屬性註解', () => {
|
||||
const sections = styles.split(/^## /m).filter((s) => s.includes('```'));
|
||||
for (const section of sections) {
|
||||
const name = section.split('\n')[0].trim();
|
||||
if (name === '未經驗證的範例怎麼標') continue;
|
||||
assert.match(section, /例:|例如/, `${name} 的範例要示範「附真實資料範例」這件事`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Go 的慣例(以識別字開頭)有被指出來,不是照抄別的語言', () => {
|
||||
assert.match(sectionOf(styles, 'Go'), /以被註解的識別字開頭/);
|
||||
});
|
||||
|
||||
test('Python 的 docstring 位置有講清楚在定義的下一行', () => {
|
||||
const python = sectionOf(styles, 'Python');
|
||||
assert.match(python, /下一行/);
|
||||
assert.match(python, /不是上一行/, '這是最容易寫錯的一點,要明講');
|
||||
});
|
||||
|
||||
test('未經驗證的註明怎麼寫,兩種語言各有一個可照抄的寫法', () => {
|
||||
const section = sectionOf(styles, '未經驗證的範例怎麼標');
|
||||
assert.match(section, /不要另起一行 TODO/);
|
||||
assert.ok((section.match(/由邏輯推理、未經驗證/g) ?? []).length >= 2, '至少要有兩種語言的寫法');
|
||||
});
|
||||
|
||||
// ── 兩份的分工 ─────────────────────────────────────────────────────
|
||||
|
||||
test('規範與格式分開:對照表不重複寫一遍規範', () => {
|
||||
assert.match(styles, /這份只管\*\*格式\*\*/);
|
||||
assert.match(standards, /comment-styles\.md/, '規範要指名去哪裡查格式');
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* 把留言裡的決策整併回議題描述。
|
||||
*
|
||||
* 兩件事錯了都很安靜,所以測試集中在這裡:
|
||||
*
|
||||
* 1. **局部更新。** 只換指定那一段,其餘一字不動。整份重寫會把別人在其他段落的
|
||||
* 編輯一起蓋掉,而議題的編輯紀錄沒有人會去比對。
|
||||
* 2. **標記只給真的整併進去的那幾則。** 略過的要保持未標記,下次才會再被提出來;
|
||||
* 而描述沒寫成功就不該標記——標了就等於這則再也不會被看到。
|
||||
*/
|
||||
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, patchOf } from './helpers/stub-gitea.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
const INDEX = 7;
|
||||
|
||||
/** 一份有多個段落的需求議題 */
|
||||
const BODY = `## 總覽
|
||||
|
||||
把一段口語需求變成結構化議題。
|
||||
|
||||
## 背景
|
||||
|
||||
需求目前寫成散文。
|
||||
|
||||
## 目標
|
||||
|
||||
- 需求議題可被下游腳本機讀
|
||||
- 建立議題的時間從 30 分鐘降到 5 分鐘
|
||||
|
||||
## 非目標
|
||||
|
||||
- 不處理工作包的拆解
|
||||
`;
|
||||
|
||||
/** 把段落內容寫成檔案,回傳路徑 */
|
||||
function contentFile(name, content) {
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
const path = join(tmpRoot, `merge-${name}-${process.hrtime.bigint()}.md`);
|
||||
writeFileSync(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
function routes(overrides = {}, { body = BODY, comments = [101, 102] } = {}) {
|
||||
const base = healthyRoutes(REPO, {
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
|
||||
status: 200,
|
||||
body: { number: INDEX, title: '以 sdlc-plan 轉成結構化需求議題', body, html_url: `https://x/${INDEX}` },
|
||||
},
|
||||
[`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`]: (req) => ({
|
||||
status: 200,
|
||||
body: { number: INDEX, ...req.body, html_url: `https://x/${INDEX}` },
|
||||
}),
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: {
|
||||
status: 200,
|
||||
body: comments.map((id) => ({ id, type: 'comment', body: `留言 ${id}`, user: { login: 'someone' } })),
|
||||
},
|
||||
});
|
||||
for (const id of comments) {
|
||||
base[`POST /api/v1/repos/${REPO}/issues/comments/${id}/reactions`] = { status: 201, body: {} };
|
||||
}
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options));
|
||||
|
||||
const run = (args, stub) =>
|
||||
runScript('comments-merge.js', ['--repo', REPO, '--index', String(INDEX), ...args], {
|
||||
env: envFor(stub),
|
||||
});
|
||||
|
||||
const reactions = (stub) =>
|
||||
stub.requests.filter((r) => r.method === 'POST' && r.path.includes('/reactions'));
|
||||
|
||||
// ── 局部更新 ───────────────────────────────────────────────────────
|
||||
|
||||
test('只換指定那一段,其餘一字不動', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('goals', '- 需求議題可被下游腳本機讀\n- 建立議題的時間降到 5 分鐘\n- 名詞表由需求提出者維護\n');
|
||||
|
||||
const { code, json } = await run(
|
||||
['--section', '目標', '--content-file', file, '--merged', '101'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
const written = patchOf(stub).body.body;
|
||||
assert.match(written, /- 名詞表由需求提出者維護/, '新內容要寫進去');
|
||||
assert.match(written, /## 總覽\n\n把一段口語需求變成結構化議題。/, '總覽原封不動');
|
||||
assert.match(written, /## 背景\n\n需求目前寫成散文。/, '背景原封不動');
|
||||
assert.match(written, /## 非目標\n\n- 不處理工作包的拆解/, '非目標原封不動');
|
||||
});
|
||||
|
||||
test('段落標題本身不動,只換它底下的內容', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('keep-heading', '- 換掉的內容\n');
|
||||
|
||||
await run(['--section', '目標', '--content-file', file, '--merged', '101'], stub);
|
||||
|
||||
const written = patchOf(stub).body.body;
|
||||
assert.equal((written.match(/^## 目標$/gm) ?? []).length, 1, '標題只有一個,沒有被複製或刪掉');
|
||||
assert.match(written, /## 目標\n\n- 換掉的內容\n\n## 非目標/);
|
||||
});
|
||||
|
||||
test('段落之間的空行維持原本的樣子', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('spacing', '- 甲\n');
|
||||
|
||||
await run(['--section', '背景', '--content-file', file, '--merged', '101'], stub);
|
||||
|
||||
assert.match(patchOf(stub).body.body, /## 背景\n\n- 甲\n\n## 目標/);
|
||||
});
|
||||
|
||||
test('最後一段也換得掉', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('last', '- 也不處理權限\n');
|
||||
|
||||
await run(['--section', '非目標', '--content-file', file, '--merged', '101'], stub);
|
||||
|
||||
const written = patchOf(stub).body.body;
|
||||
assert.match(written, /## 非目標\n\n- 也不處理權限/);
|
||||
assert.equal(written.includes('不處理工作包的拆解'), false, '舊內容要被換掉');
|
||||
});
|
||||
|
||||
test('段落不存在時擋下,不把內容補到別的地方去', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('missing', '- 內容\n');
|
||||
|
||||
const { code, json } = await run(
|
||||
['--section', '沒有這一段', '--content-file', file, '--merged', '101'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'SECTION_NOT_FOUND');
|
||||
assert.equal(patchOf(stub), undefined);
|
||||
assert.deepEqual(reactions(stub), [], '沒寫進去就不該標記');
|
||||
});
|
||||
|
||||
test('圍欄裡的假標題不算段落', async (t) => {
|
||||
const body = '## 流程圖\n\n```\n## 目標\n這不是段落\n```\n\n## 目標\n\n- 真的目標\n';
|
||||
const stub = await withStub(t, {}, { body });
|
||||
const file = contentFile('fenced', '- 換掉的目標\n');
|
||||
|
||||
await run(['--section', '目標', '--content-file', file, '--merged', '101'], stub);
|
||||
|
||||
const written = patchOf(stub).body.body;
|
||||
assert.match(written, /```\n## 目標\n這不是段落\n```/, '圍欄裡的內容原封不動');
|
||||
assert.match(written, /## 目標\n\n- 換掉的目標/);
|
||||
});
|
||||
|
||||
// ── 標記:只給真的整併進去的 ───────────────────────────────────────
|
||||
|
||||
test('只標記 --merged 列出的那幾則', async (t) => {
|
||||
const stub = await withStub(t, {}, { comments: [101, 102, 103] });
|
||||
const file = contentFile('partial', '- 內容\n');
|
||||
|
||||
const { json } = await run(
|
||||
['--section', '目標', '--content-file', file, '--merged', '101,103'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.deepEqual(json.data.已標記, [101, 103]);
|
||||
assert.deepEqual(
|
||||
reactions(stub).map((r) => r.path),
|
||||
[
|
||||
`/api/v1/repos/${REPO}/issues/comments/101/reactions`,
|
||||
`/api/v1/repos/${REPO}/issues/comments/103/reactions`,
|
||||
],
|
||||
'沒被整併的 102 要保持未標記,下次才會再被提出來',
|
||||
);
|
||||
});
|
||||
|
||||
test('標記送的是 +1', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('thumb', '- 內容\n');
|
||||
|
||||
await run(['--section', '目標', '--content-file', file, '--merged', '101'], stub);
|
||||
|
||||
assert.deepEqual(reactions(stub)[0].body, { content: '+1' });
|
||||
});
|
||||
|
||||
test('先寫描述再標記:標記是「這則已經收進去了」的結論', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('order', '- 內容\n');
|
||||
|
||||
await run(['--section', '目標', '--content-file', file, '--merged', '101'], stub);
|
||||
|
||||
const writes = stub.requests
|
||||
.filter((r) => r.method !== 'GET' && !r.path.endsWith('/issues/0'))
|
||||
.map((r) => r.path);
|
||||
assert.ok(
|
||||
writes.indexOf(`/api/v1/repos/${REPO}/issues/${INDEX}`)
|
||||
< writes.indexOf(`/api/v1/repos/${REPO}/issues/comments/101/reactions`),
|
||||
);
|
||||
});
|
||||
|
||||
test('描述寫入失敗時不標記:標了就等於這則再也不會被看到', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 403, body: { message: 'forbidden' } },
|
||||
});
|
||||
const file = contentFile('fail', '- 內容\n');
|
||||
|
||||
const { code } = await run(['--section', '目標', '--content-file', file, '--merged', '101'], stub);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.deepEqual(reactions(stub), []);
|
||||
});
|
||||
|
||||
test('--merged 指到議題上沒有的留言時擋下', async (t) => {
|
||||
const stub = await withStub(t, {}, { comments: [101] });
|
||||
const file = contentFile('badid', '- 內容\n');
|
||||
|
||||
const { json } = await run(
|
||||
['--section', '目標', '--content-file', file, '--merged', '101,999'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(json.error.code, 'COMMENT_NOT_FOUND');
|
||||
assert.match(json.error.message, /999/);
|
||||
assert.equal(patchOf(stub), undefined);
|
||||
});
|
||||
|
||||
// ── 冪等 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('內容與現況相同時不重寫描述,但該標記的還是要標', async (t) => {
|
||||
// 重跑常常是因為上一輪標記那一步斷掉了
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('same', '- 需求議題可被下游腳本機讀\n- 建立議題的時間從 30 分鐘降到 5 分鐘\n');
|
||||
|
||||
const { code, json } = await run(
|
||||
['--section', '目標', '--content-file', file, '--merged', '101'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
assert.equal(json.data.描述已更新, false);
|
||||
assert.equal(patchOf(stub), undefined, '沒變就不要在議題上留一筆空的編輯');
|
||||
assert.equal(reactions(stub).length, 1, '標記照舊');
|
||||
});
|
||||
|
||||
// ── 輸入 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('內容是空的時候擋下:整併不該把一段清空', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('empty', ' \n');
|
||||
|
||||
const { json } = await run(
|
||||
['--section', '目標', '--content-file', file, '--merged', '101'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(json.error.code, 'FILE_EMPTY');
|
||||
assert.equal(patchOf(stub), undefined);
|
||||
});
|
||||
|
||||
test('內容檔不存在時回可區分的錯誤碼', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(
|
||||
['--section', '目標', '--content-file', join(tmpRoot, '不存在.md'), '--merged', '101'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(json.error.code, 'FILE_NOT_FOUND');
|
||||
});
|
||||
|
||||
test('沒有 --merged 時擋下:整併卻不標記,下次會重複處理同一則', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('nomerged', '- 內容\n');
|
||||
|
||||
const { json } = await runScript('comments-merge.js', [
|
||||
'--repo', REPO, '--index', String(INDEX), '--section', '目標', '--content-file', file,
|
||||
], { env: envFor(stub) });
|
||||
|
||||
assert.equal(json.error.code, 'MISSING_FLAG');
|
||||
assert.match(json.error.message, /--merged/);
|
||||
});
|
||||
|
||||
// ── --dry-run ─────────────────────────────────────────────────────
|
||||
|
||||
test('--dry-run 印出改完的描述與將標記的留言,但不寫入', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('dry', '- 換掉的目標\n');
|
||||
|
||||
const { code, json } = await run(
|
||||
['--section', '目標', '--content-file', file, '--merged', '101,102', '--dry-run'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.dryRun, true);
|
||||
assert.match(json.data.requests[0].body.body, /- 換掉的目標/);
|
||||
assert.deepEqual(
|
||||
json.data.requests.map((r) => `${r.method} ${r.path}`),
|
||||
[
|
||||
`PATCH /repos/${REPO}/issues/${INDEX}`,
|
||||
`POST /repos/${REPO}/issues/comments/101/reactions`,
|
||||
`POST /repos/${REPO}/issues/comments/102/reactions`,
|
||||
],
|
||||
);
|
||||
assert.equal(patchOf(stub), undefined);
|
||||
assert.deepEqual(reactions(stub), []);
|
||||
});
|
||||
|
||||
test('--dry-run 遇到段落不存在一樣報錯,不會等到實跑才發現', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('dry-bad', '- 內容\n');
|
||||
|
||||
const { json } = await run(
|
||||
['--section', '沒有這一段', '--content-file', file, '--merged', '101', '--dry-run'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(json.error.code, 'SECTION_NOT_FOUND');
|
||||
});
|
||||
|
||||
test('--dry-run 在內容沒變時不預告 PATCH', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = contentFile('dry-same', '- 需求議題可被下游腳本機讀\n- 建立議題的時間從 30 分鐘降到 5 分鐘\n');
|
||||
|
||||
const { json } = await run(
|
||||
['--section', '目標', '--content-file', file, '--merged', '101', '--dry-run'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
json.data.requests.map((r) => `${r.method} ${r.path}`),
|
||||
[`POST /repos/${REPO}/issues/comments/101/reactions`],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* 把變更分批 commit。
|
||||
*
|
||||
* 兩件事各自要驗:
|
||||
* 1. **類型分類**是純字串規則,表格驅動——它決定 git 歷史讀不讀得懂,
|
||||
* 而錯了之後要改歷史才修得回來。
|
||||
* 2. **分批的界線**:一個 commit 只裝一種類型,程式碼與測試不混在一起,
|
||||
* reviewer 才能一次只看一件事。
|
||||
*
|
||||
* git 不做 mock:在臨時 repo 上跑真的 git 比假的 git 可信,成本也低。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { runScript } from './helpers/run-script.js';
|
||||
import { makeTempRepo } from './helpers/temp-repo.js';
|
||||
|
||||
function withRepo(t) {
|
||||
const repo = makeTempRepo();
|
||||
t.after(() => repo.cleanup());
|
||||
return repo;
|
||||
}
|
||||
|
||||
/** 在 repo 裡寫幾個檔案(含目錄),模擬一次實作留下的變更 */
|
||||
function write(repo, ...paths) {
|
||||
for (const path of paths) {
|
||||
const full = join(repo.dir, path);
|
||||
mkdirSync(dirname(full), { recursive: true });
|
||||
writeFileSync(full, `// ${path}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const run = (repo, args) => runScript('commit-split.js', ['--path', repo.dir, ...args]);
|
||||
|
||||
/** 初始 commit 之後新增的 commit 訊息首行,由舊到新 */
|
||||
const subjects = (repo) =>
|
||||
repo.git('log', '--format=%s', '--reverse').split('\n').filter((line) => line !== '').slice(1);
|
||||
|
||||
// ── 類型分類:表格驅動 ─────────────────────────────────────────────
|
||||
|
||||
const CLASSIFY = [
|
||||
{ path: 'test/claim.test.js', type: 'test', why: '測試檔' },
|
||||
{ path: 'test/helpers/stub-gitea.js', type: 'test', why: '測試用的 helper 也算測試' },
|
||||
{ path: 'tests/user_test.py', type: 'test', why: '目標專案未必叫 test/' },
|
||||
{ path: 'spec/user_spec.rb', type: 'test', why: '同上' },
|
||||
{ path: '__tests__/user.js', type: 'test', why: '同上' },
|
||||
{ path: 'src/user.test.js', type: 'test', why: '測試與程式碼放在一起也很常見' },
|
||||
{ path: 'src/User.spec.ts', type: 'test', why: '同上' },
|
||||
{ path: 'README.md', type: 'docs', why: '根目錄的說明文件' },
|
||||
{ path: 'AGENTS.md', type: 'docs', why: '同上' },
|
||||
{ path: 'package.json', type: 'chore', why: '專案設定' },
|
||||
{ path: '.gitignore', type: 'chore', why: '同上' },
|
||||
{ path: 'scripts/claim.js', type: null, why: '看不出是新功能還是修 bug,要由呼叫端指定' },
|
||||
{ path: 'prompts/sdlc-feat.md', type: null, why: '流程正本是產品的一部分,同上' },
|
||||
{ path: 'references/coding-standards.md', type: null, why: '規則正本同上' },
|
||||
{ path: 'templates/work-package-issue.md', type: null, why: '輸出模板同上' },
|
||||
];
|
||||
|
||||
for (const { path, type, why } of CLASSIFY) {
|
||||
test(`分類:${path} → ${type ?? '由 --type 決定'}(${why})`, async (t) => {
|
||||
const repo = withRepo(t);
|
||||
write(repo, path);
|
||||
|
||||
const { code, json } = await run(repo, ['--type', 'feat', '--subject', '做了一件事']);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
assert.equal(json.data.commits.length, 1);
|
||||
assert.match(json.data.commits[0].message, new RegExp(`^${type ?? 'feat'}\\(`));
|
||||
});
|
||||
}
|
||||
|
||||
// ── 分批:一個 commit 只裝一種類型 ─────────────────────────────────
|
||||
|
||||
test('程式碼與測試分成兩個 commit,不混在一起', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
write(repo, 'scripts/claim.js', 'test/claim.test.js');
|
||||
|
||||
const { code, json } = await run(repo, ['--type', 'feat', '--subject', '領取工作包']);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
assert.deepEqual(subjects(repo), [
|
||||
'feat(claim): 領取工作包',
|
||||
'test(claim): 領取工作包',
|
||||
]);
|
||||
});
|
||||
|
||||
test('四種類型都出現時分成四個 commit,順序為先程式碼後周邊', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
write(repo, 'scripts/claim.js', 'test/claim.test.js', 'README.md', 'package.json');
|
||||
|
||||
const { json } = await run(repo, ['--type', 'feat', '--scope', '領取', '--subject', '領取工作包']);
|
||||
|
||||
assert.deepEqual(json.data.commits.map((c) => c.message), [
|
||||
'feat(claim): 領取工作包',
|
||||
'test(claim): 領取工作包',
|
||||
'docs(README): 領取工作包',
|
||||
'chore(package): 領取工作包',
|
||||
]);
|
||||
});
|
||||
|
||||
test('每個 commit 只含它自己那一批檔案', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
write(repo, 'scripts/claim.js', 'test/claim.test.js');
|
||||
|
||||
await run(repo, ['--type', 'feat', '--subject', '領取工作包']);
|
||||
|
||||
const firstFiles = repo.git('show', '--name-only', '--format=', 'HEAD~1').split('\n').filter(Boolean);
|
||||
const secondFiles = repo.git('show', '--name-only', '--format=', 'HEAD').split('\n').filter(Boolean);
|
||||
assert.deepEqual(firstFiles, ['scripts/claim.js']);
|
||||
assert.deepEqual(secondFiles, ['test/claim.test.js']);
|
||||
});
|
||||
|
||||
test('工作區在跑完之後是乾淨的:沒有檔案被漏掉', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
write(repo, 'scripts/claim.js', 'test/claim.test.js', 'README.md', 'package.json');
|
||||
|
||||
await run(repo, ['--type', 'feat', '--scope', '領取', '--subject', '領取工作包']);
|
||||
|
||||
assert.equal(repo.git('status', '--porcelain'), '');
|
||||
});
|
||||
|
||||
// ── scope:單檔用檔名,多檔用功能名 ───────────────────────────────
|
||||
|
||||
test('一批只有一個檔案時,scope 是那個檔名(去掉目錄與副檔名)', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
write(repo, 'scripts/branch-prep.js');
|
||||
|
||||
const { json } = await run(repo, ['--type', 'feat', '--subject', '備妥分支']);
|
||||
|
||||
assert.equal(json.data.commits[0].message, 'feat(branch-prep): 備妥分支');
|
||||
});
|
||||
|
||||
test('一批有多個檔案時,scope 是 --scope 給的功能名', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
write(repo, 'scripts/claim.js', 'scripts/branch-prep.js');
|
||||
|
||||
const { json } = await run(repo, ['--type', 'feat', '--scope', '領取與分支', '--subject', '備妥開工']);
|
||||
|
||||
assert.equal(json.data.commits[0].message, 'feat(領取與分支): 備妥開工');
|
||||
});
|
||||
|
||||
test('多檔卻沒給 --scope 時擋下,並說明什麼時候要給', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
write(repo, 'scripts/claim.js', 'scripts/branch-prep.js');
|
||||
|
||||
const { code, json } = await run(repo, ['--type', 'feat', '--subject', '備妥開工']);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'SCOPE_REQUIRED');
|
||||
assert.match(json.error.message, /多檔/);
|
||||
assert.equal(repo.git('status', '--porcelain') === '', false, '擋下來就不該已經提交掉');
|
||||
});
|
||||
|
||||
test('--scope 只在多檔那幾批生效,單檔那批仍用檔名', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
write(repo, 'scripts/claim.js', 'scripts/branch-prep.js', 'test/claim.test.js');
|
||||
|
||||
const { json } = await run(repo, ['--type', 'feat', '--scope', '領取與分支', '--subject', '備妥開工']);
|
||||
|
||||
assert.deepEqual(json.data.commits.map((c) => c.message), [
|
||||
'feat(領取與分支): 備妥開工',
|
||||
'test(claim): 備妥開工',
|
||||
]);
|
||||
});
|
||||
|
||||
// ── --files:一次只處理一個功能 ───────────────────────────────────
|
||||
|
||||
test('--files 只提交指定的那幾個檔案,其餘原封不動留著', async (t) => {
|
||||
// 正本要求「一次變更橫跨兩個不相干的功能時分兩次跑」,那就得有辦法只處理一部分
|
||||
const repo = withRepo(t);
|
||||
write(repo, 'scripts/claim.js', 'scripts/branch-prep.js');
|
||||
|
||||
const { code, json } = await run(repo, [
|
||||
'--type', 'feat', '--subject', '領取工作包', '--files', 'scripts/claim.js',
|
||||
]);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
assert.deepEqual(json.data.commits.map((c) => c.message), ['feat(claim): 領取工作包']);
|
||||
assert.equal(
|
||||
repo.git('status', '--porcelain').includes('branch-prep.js'),
|
||||
true,
|
||||
'沒被指定的檔案要留在工作區',
|
||||
);
|
||||
});
|
||||
|
||||
test('--files 指定多個檔案時照樣依類型分批', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
write(repo, 'scripts/claim.js', 'test/claim.test.js', 'scripts/branch-prep.js');
|
||||
|
||||
const { json } = await run(repo, [
|
||||
'--type', 'feat', '--subject', '領取工作包',
|
||||
'--files', 'scripts/claim.js,test/claim.test.js',
|
||||
]);
|
||||
|
||||
assert.deepEqual(json.data.commits.map((c) => c.message), [
|
||||
'feat(claim): 領取工作包',
|
||||
'test(claim): 領取工作包',
|
||||
]);
|
||||
});
|
||||
|
||||
test('--files 指到沒有變更的檔案時擋下,不默默少做', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
write(repo, 'scripts/claim.js');
|
||||
|
||||
const { code, json } = await run(repo, [
|
||||
'--type', 'feat', '--subject', '領取工作包', '--files', 'scripts/claim.js,scripts/沒改過.js',
|
||||
]);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'FILE_NOT_CHANGED');
|
||||
assert.match(json.error.message, /沒改過/);
|
||||
});
|
||||
|
||||
// ── 訊息格式 ───────────────────────────────────────────────────────
|
||||
|
||||
test('描述要用繁體中文,純英文的描述會被擋下', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
write(repo, 'scripts/claim.js');
|
||||
|
||||
const { code, json } = await run(repo, ['--type', 'feat', '--subject', 'claim the work package']);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'SUBJECT_NOT_CHINESE');
|
||||
assert.match(json.error.message, /繁體中文|中文/);
|
||||
});
|
||||
|
||||
test('描述夾雜英文是可以的,只要有中文', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
write(repo, 'scripts/claim.js');
|
||||
|
||||
const { code, json } = await run(repo, ['--type', 'feat', '--subject', '讓 claim 擋住他人已認領的工作包']);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
assert.equal(json.data.commits[0].message, 'feat(claim): 讓 claim 擋住他人已認領的工作包');
|
||||
});
|
||||
|
||||
test('--type 不是既定分類時擋下,並列出可用的', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
write(repo, 'scripts/claim.js');
|
||||
|
||||
const { json } = await run(repo, ['--type', 'feature', '--subject', '做了一件事']);
|
||||
|
||||
assert.equal(json.error.code, 'BAD_TYPE');
|
||||
assert.match(json.error.message, /feat/);
|
||||
});
|
||||
|
||||
// ── 訊息本體 ───────────────────────────────────────────────────────
|
||||
|
||||
test('--body 接在首行之後,中間空一行', async (t) => {
|
||||
// 本 repo 的每一顆 commit 都說明「為什麼這樣做」,工具產出的歷史不該只有首行
|
||||
const repo = withRepo(t);
|
||||
write(repo, 'scripts/claim.js');
|
||||
|
||||
const { code, json } = await run(repo, [
|
||||
'--type', 'feat', '--subject', '領取工作包',
|
||||
'--body', '鎖用 assignee 加標籤,不用碼錶——Gitea 只讀得到自己的錶。',
|
||||
]);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
assert.equal(
|
||||
repo.git('log', '-1', '--format=%B').trim(),
|
||||
'feat(claim): 領取工作包\n\n鎖用 assignee 加標籤,不用碼錶——Gitea 只讀得到自己的錶。',
|
||||
);
|
||||
});
|
||||
|
||||
test('同一批變更的每一顆 commit 共用同一段說明', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
write(repo, 'scripts/claim.js', 'test/claim.test.js');
|
||||
|
||||
await run(repo, ['--type', 'feat', '--subject', '領取工作包', '--body', '說明為什麼。']);
|
||||
|
||||
for (const ref of ['HEAD', 'HEAD~1']) {
|
||||
assert.match(repo.git('log', '-1', '--format=%b', ref), /說明為什麼。/);
|
||||
}
|
||||
});
|
||||
|
||||
test('沒給 --body 時訊息就只有首行,不補空行', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
write(repo, 'scripts/claim.js');
|
||||
|
||||
await run(repo, ['--type', 'feat', '--subject', '領取工作包']);
|
||||
|
||||
assert.equal(repo.git('log', '-1', '--format=%B').trim(), 'feat(claim): 領取工作包');
|
||||
});
|
||||
|
||||
// ── 沒有東西可提交 ─────────────────────────────────────────────────
|
||||
|
||||
test('工作區乾淨時回可區分的錯誤碼,不做出一顆空 commit', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
|
||||
const { code, json } = await run(repo, ['--type', 'feat', '--subject', '什麼都沒改']);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'NOTHING_TO_COMMIT');
|
||||
});
|
||||
|
||||
// ── 刪除與改名 ─────────────────────────────────────────────────────
|
||||
|
||||
test('被刪掉的檔案也照樣分類、照樣進 commit', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
write(repo, 'scripts/old.js');
|
||||
repo.git('add', '-A');
|
||||
repo.git('commit', '-qm', '先有這個檔案');
|
||||
repo.git('rm', '-q', 'scripts/old.js');
|
||||
|
||||
const { code, json } = await run(repo, ['--type', 'refactor', '--subject', '移除不再使用的腳本']);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
assert.equal(json.data.commits[0].message, 'refactor(old): 移除不再使用的腳本');
|
||||
assert.equal(repo.git('status', '--porcelain'), '');
|
||||
});
|
||||
|
||||
test('改名時舊檔的刪除也要進 commit,不能只提交新檔', async (t) => {
|
||||
// git diff --name-only 預設偵測改名,只印目的地那一個路徑。漏掉來源等於把刪除留在
|
||||
// index 裡,而腳本還回報成功——下一次跑才會發現工作區不乾淨。
|
||||
const repo = withRepo(t);
|
||||
write(repo, 'scripts/old.js');
|
||||
repo.git('add', '-A');
|
||||
repo.git('commit', '-qm', '先有這個檔案');
|
||||
repo.git('mv', 'scripts/old.js', 'scripts/new.js');
|
||||
|
||||
const { code, json } = await run(repo, ['--type', 'refactor', '--scope', '改名', '--subject', '換個名字']);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
assert.deepEqual(json.data.commits[0].files, ['scripts/new.js', 'scripts/old.js']);
|
||||
assert.equal(repo.git('status', '--porcelain'), '', '改名的兩邊都要進同一顆 commit');
|
||||
});
|
||||
|
||||
// ── 中途失敗 ───────────────────────────────────────────────────────
|
||||
|
||||
test('某一批提交失敗時,錯誤要說出前面已經建立了哪幾顆 commit', async (t) => {
|
||||
// 沒說的話,使用者不知道做到哪裡,重跑前得自己去翻 git log
|
||||
const repo = withRepo(t);
|
||||
write(repo, 'scripts/one.js', 'test/one.test.js');
|
||||
// 用 pre-commit hook 擋掉測試那一批
|
||||
const hook = join(repo.dir, '.git', 'hooks', 'pre-commit');
|
||||
mkdirSync(dirname(hook), { recursive: true });
|
||||
writeFileSync(hook, '#!/bin/sh\ngit diff --cached --name-only | grep -q "^test/" && exit 1\nexit 0\n', { mode: 0o755 });
|
||||
|
||||
const { code, json } = await run(repo, ['--type', 'feat', '--subject', '做一件事']);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'COMMIT_FAILED');
|
||||
assert.match(json.error.message, /feat\(one\): 做一件事/, '要指名已經建立的那一顆');
|
||||
assert.match(json.error.message, /test\(one\)/, '也要指名是哪一批失敗的');
|
||||
});
|
||||
|
||||
// ── --dry-run ─────────────────────────────────────────────────────
|
||||
|
||||
test('--dry-run 印出將建立的 commit 與各自的檔案,但不提交', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
write(repo, 'scripts/claim.js', 'test/claim.test.js');
|
||||
const before = repo.git('rev-parse', 'HEAD');
|
||||
|
||||
const { code, json } = await run(repo, ['--type', 'feat', '--subject', '領取工作包', '--dry-run']);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.dryRun, true);
|
||||
assert.deepEqual(json.data.commits, [
|
||||
{ message: 'feat(claim): 領取工作包', files: ['scripts/claim.js'] },
|
||||
{ message: 'test(claim): 領取工作包', files: ['test/claim.test.js'] },
|
||||
]);
|
||||
assert.equal(repo.git('rev-parse', 'HEAD'), before, '試跑不該產生 commit');
|
||||
assert.equal(repo.git('status', '--porcelain') === '', false, '變更要原封不動留著');
|
||||
});
|
||||
|
||||
test('--dry-run 在多檔缺 --scope 時一樣報錯,不會等到實跑才發現', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
write(repo, 'scripts/claim.js', 'scripts/branch-prep.js');
|
||||
|
||||
const { json } = await run(repo, ['--type', 'feat', '--subject', '備妥開工', '--dry-run']);
|
||||
|
||||
assert.equal(json.error.code, 'SCOPE_REQUIRED');
|
||||
});
|
||||
|
||||
// ── 路徑 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('--path 不是 git repo 時回可區分的錯誤碼', async (t) => {
|
||||
const { json } = await runScript('commit-split.js', [
|
||||
'--path', '/', '--type', 'feat', '--subject', '做了一件事',
|
||||
]);
|
||||
|
||||
assert.equal(json.error.code, 'NOT_A_GIT_REPO');
|
||||
});
|
||||
|
||||
test('git 自己的訊息不漏到 stderr', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
write(repo, 'scripts/claim.js');
|
||||
|
||||
const { stderr } = await run(repo, ['--type', 'feat', '--subject', '領取工作包']);
|
||||
|
||||
assert.equal(stderr, '');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 斷言模板的 `## 標題` 就是這組段落,順序一致。
|
||||
* 段落順序即下游抽取契約的解析依據,兩邊必須一起改。
|
||||
|
||||
@@ -103,3 +103,13 @@ export async function withStubGitea(t, routes) {
|
||||
|
||||
/** 把腳本指向這台假 Gitea 的環境變數 */
|
||||
export const stubEnv = (stub) => ({ TEA_SDLC_API_BASE: stub.base, TEA_SDLC_TOKEN: 'stub-token' });
|
||||
|
||||
/**
|
||||
* 找出腳本真正發出的那一個 PATCH。
|
||||
* 前置檢查對 `issues/0` 的探針也是 PATCH,但它打在一顆不存在的議題上、不改動任何東西,
|
||||
* 不該被當成腳本的寫入(見 lib.js 的 checkIssueWrite)。
|
||||
* @returns {object|undefined} 沒發出寫入時為 undefined
|
||||
*/
|
||||
export function patchOf(stub) {
|
||||
return stub.requests.find((r) => r.method === 'PATCH' && !r.path.endsWith('/issues/0'));
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import { join } from 'node:path';
|
||||
import { tmpRoot } from './run-script.js';
|
||||
|
||||
/**
|
||||
* @returns {{dir: string, cleanup: Function}} dir 為已有一顆 commit 的 git repo
|
||||
* @returns {{dir: string, git: Function, cleanup: Function}} dir 為已有一顆 commit 的 git repo,
|
||||
* git 為綁在它身上的執行器(與 makeTempRepoWithRemote 對稱)
|
||||
*/
|
||||
export function makeTempRepo() {
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
@@ -18,7 +19,7 @@ export function makeTempRepo() {
|
||||
git('init', '-q', '-b', 'master');
|
||||
seed(git, dir, 'tester', 'tester@example.com');
|
||||
|
||||
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
||||
return { dir, git, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
+40
-23
@@ -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,
|
||||
});
|
||||
@@ -85,7 +85,7 @@ function routes(overrides = {}, { body = FULL_BODY, comments = [] } = {}) {
|
||||
comments.forEach((c, i) => {
|
||||
base[`GET /api/v1/repos/${REPO}/issues/comments/${100 + i}/reactions`] = {
|
||||
status: 200,
|
||||
body: (c.reactions ?? []).map((content) => ({ content })),
|
||||
body: (c.reactions ?? []).map((content) => ({ content, user: { login: c.reactedBy ?? 'tester' } })),
|
||||
};
|
||||
});
|
||||
return { ...base, ...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/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* 勾選待辦:以抽取契約給的 `raw` 做精確字串替換。
|
||||
*
|
||||
* 這一支的全部價值在「只動目標那一行」。改壞的代價很安靜——議題上的進度條會說謊,
|
||||
* 而沒有人會去比對 body 的編輯紀錄。所以三種危險各有測試:
|
||||
* - 同一句話在 body 裡出現兩次(巢狀待辦底下常有一模一樣的驗收,例如「加上測試」)
|
||||
* - `raw` 對不上(議題被人改過,手上的抽取結果已經過期)
|
||||
* - 已經勾過了(中斷後重跑)
|
||||
* 前兩種寧可報錯也不猜,第三種要安靜地當作沒事。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { runScript } from './helpers/run-script.js';
|
||||
import { healthyRoutes, stubEnv as envFor, withStubGitea, patchOf } from './helpers/stub-gitea.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
const INDEX = 12;
|
||||
|
||||
/**
|
||||
* 一份有巢狀待辦的工作包 body。刻意埋了三個地雷:
|
||||
* - 兩項驗收的文字一模一樣(同一段落內,真的分不出來)
|
||||
* - 架構圖是 fenced mermaid,裡面有一行長得像 checkbox
|
||||
* - 整體驗收裡有一行與待辦完全相同(不同段落,靠 --section 分得出來)
|
||||
*/
|
||||
const BODY = `## 架構圖
|
||||
|
||||
\`\`\`mermaid
|
||||
flowchart TD
|
||||
A[讀議題] --> B[勾待辦]
|
||||
- [ ] 解析九個段落
|
||||
\`\`\`
|
||||
|
||||
## 待辦
|
||||
|
||||
- [ ] 解析九個段落
|
||||
- [ ] 缺段落回空值
|
||||
- [ ] 加上測試
|
||||
- [ ] 待辦解析成巢狀結構
|
||||
- [ ] 加上測試
|
||||
|
||||
## 整體驗收
|
||||
|
||||
- [ ] 輸出欄位與契約完全一致
|
||||
- [ ] 待辦解析成巢狀結構
|
||||
`;
|
||||
|
||||
function routes(overrides = {}, { body = BODY } = {}) {
|
||||
return healthyRoutes(REPO, {
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
|
||||
status: 200,
|
||||
body: { number: INDEX, title: '逐項實作並即時勾選待辦', body, html_url: 'https://example.com/12' },
|
||||
},
|
||||
[`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`]: (req) => ({
|
||||
status: 200,
|
||||
body: { number: INDEX, ...req.body, html_url: 'https://example.com/12' },
|
||||
}),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options));
|
||||
|
||||
/**
|
||||
* 把「待辦」段落裡的某一行換成指定寫法。
|
||||
* 不直接對整份 BODY 做 replace:圍欄裡那一行排在待辦之前,會被換掉的是它。
|
||||
*/
|
||||
function withTodoLine(from, to) {
|
||||
const at = BODY.indexOf('## 待辦');
|
||||
return BODY.slice(0, at) + BODY.slice(at).replace(from, to);
|
||||
}
|
||||
|
||||
const run = (args, stub) =>
|
||||
runScript('issue-update.js', ['--repo', REPO, '--index', String(INDEX), ...args], {
|
||||
env: envFor(stub),
|
||||
});
|
||||
|
||||
|
||||
// ── 精確替換 ───────────────────────────────────────────────────────
|
||||
|
||||
test('勾起指定的那一行,其餘一字不動', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
const body = patchOf(stub).body.body;
|
||||
assert.match(body, /- \[x\] 解析九個段落/);
|
||||
assert.equal(
|
||||
body.replace('- [x] 解析九個段落', '- [ ] 解析九個段落'),
|
||||
BODY,
|
||||
'把那一個方框換回去之後,應該逐字等於原本的 body',
|
||||
);
|
||||
});
|
||||
|
||||
test('縮排的驗收項目也勾得到,縮排原樣保留', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code } = await run(['--tick', ' - [ ] 缺段落回空值', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.match(patchOf(stub).body.body, /\n {2}- \[x\] 缺段落回空值\n/);
|
||||
});
|
||||
|
||||
test('回報勾起來的是哪一行,讓呼叫端印進度', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(json.data.勾起的那一行, '- [x] 解析九個段落');
|
||||
assert.equal(json.data.已經勾過, false);
|
||||
});
|
||||
|
||||
// ── 文字重複時不誤傷 ───────────────────────────────────────────────
|
||||
|
||||
test('同一句話在 body 裡出現兩次時報錯,不賭第一個', async (t) => {
|
||||
// 巢狀待辦底下常有一模一樣的驗收;猜錯的話,議題上的進度條會指著錯的那一項
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run(['--tick', ' - [ ] 加上測試', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'RAW_AMBIGUOUS');
|
||||
assert.match(json.error.message, /2/, '要說出它出現了幾次');
|
||||
assert.equal(patchOf(stub), undefined, '分不出是哪一行就不要寫');
|
||||
});
|
||||
|
||||
// ── raw 對不上 ─────────────────────────────────────────────────────
|
||||
|
||||
test('raw 不匹配時回錯誤,不盲改', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run(['--tick', '- [ ] 這一行議題上沒有', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'RAW_NOT_FOUND');
|
||||
assert.match(json.error.message, /重新抽取|過期/, '要指出手上的抽取結果可能過期了');
|
||||
assert.equal(patchOf(stub), undefined);
|
||||
});
|
||||
|
||||
test('差一個空白也算對不上:精確替換就是要精確', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(json.error.code, 'RAW_NOT_FOUND');
|
||||
});
|
||||
|
||||
// ── 冪等:中斷後重跑 ───────────────────────────────────────────────
|
||||
|
||||
test('已經勾過的項目不再動它,也不發 PATCH', async (t) => {
|
||||
const body = withTodoLine('- [ ] 解析九個段落', '- [x] 解析九個段落');
|
||||
const stub = await withStub(t, {}, { body });
|
||||
|
||||
const { code, json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(code, 0, '重跑不該失敗,那會讓中斷後的接續變成人工作業');
|
||||
assert.equal(json.data.已經勾過, true);
|
||||
assert.equal(patchOf(stub), undefined, '沒有變化就不要在議題上留下一筆空的編輯');
|
||||
});
|
||||
|
||||
test('直接給已勾的那一行也算數,同樣是 no-op', async (t) => {
|
||||
const body = withTodoLine('- [ ] 解析九個段落', '- [x] 解析九個段落');
|
||||
const stub = await withStub(t, {}, { body });
|
||||
|
||||
const { code, json } = await run(['--tick', '- [x] 解析九個段落', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.已經勾過, true);
|
||||
});
|
||||
|
||||
test('大寫的 [X] 重跑時也是安靜的 no-op,不是 RAW_NOT_FOUND', async (t) => {
|
||||
// [X] 是合法的 GFM,Gitea 會把它渲染成已勾,wp-extract 也回報 done:true。
|
||||
// 比對時若只認小寫,中斷後重跑會硬失敗,而錯誤訊息還會誣指「議題被改過」。
|
||||
const body = withTodoLine('- [ ] 待辦解析成巢狀結構', '- [X] 待辦解析成巢狀結構');
|
||||
const stub = await withStub(t, {}, { body });
|
||||
|
||||
const { code, json } = await run(['--tick', '- [X] 待辦解析成巢狀結構', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
assert.equal(json.data.已經勾過, true);
|
||||
assert.equal(patchOf(stub), undefined);
|
||||
});
|
||||
|
||||
// ── 輸入驗證 ───────────────────────────────────────────────────────
|
||||
|
||||
test('--tick 的內容根本不是清單項時擋下', async (t) => {
|
||||
// 「是清單項但忘了寫方框」是另一種情況,錯誤碼不同——那種要指路去議題上補
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(['--tick', '解析九個段落'], stub);
|
||||
|
||||
assert.equal(json.error.code, 'BAD_RAW');
|
||||
assert.match(json.error.message, /清單項/);
|
||||
});
|
||||
|
||||
test('--section 沒有配 --tick 時說清楚它沒有作用', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(['--section', '待辦', '--milestone', '第一階段'], stub);
|
||||
|
||||
assert.equal(json.error.code, 'MISSING_FLAG');
|
||||
assert.match(json.error.message, /--tick/);
|
||||
});
|
||||
|
||||
test('--tick 夾帶換行時擋下:一次只勾一行', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(['--tick', '- [ ] 甲\n- [ ] 乙'], stub);
|
||||
|
||||
assert.equal(json.error.code, 'BAD_RAW');
|
||||
});
|
||||
|
||||
// ── 圍欄與段落:不誤傷、也不假歧義 ─────────────────────────────────
|
||||
|
||||
test('圍欄裡長得像 checkbox 的那一行不算,不會被改到', async (t) => {
|
||||
// 工作包模板的架構圖就是一塊 fenced mermaid,裡面出現減號開頭的行是常態。
|
||||
// issue-body.js 全檔的前提是「圍欄裡的東西不是內容」,勾選是唯一會寫回去的路徑,
|
||||
// 漏掉這件事就會靜靜改壞圖。
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code } = await run(['--tick', '- [ ] 解析九個段落', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(code, 0);
|
||||
const body = patchOf(stub).body.body;
|
||||
const fence = body.slice(body.indexOf('```mermaid'), body.indexOf('## 待辦'));
|
||||
assert.match(fence, /- \[ \] 解析九個段落/, '圍欄裡那一行要原封不動');
|
||||
});
|
||||
|
||||
test('不同段落有同一行時,--section 分得出來', async (t) => {
|
||||
// 待辦與整體驗收各有一行「待辦解析成巢狀結構」,限定段落就不該是歧義
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run(['--tick', '- [ ] 待辦解析成巢狀結構', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
const body = patchOf(stub).body.body;
|
||||
const todo = body.slice(body.indexOf('## 待辦'), body.indexOf('## 整體驗收'));
|
||||
const overall = body.slice(body.indexOf('## 整體驗收'));
|
||||
assert.match(todo, /- \[x\] 待辦解析成巢狀結構/, '待辦那一行要被勾起');
|
||||
assert.match(overall, /- \[ \] 待辦解析成巢狀結構/, '整體驗收那一行不該被動到');
|
||||
});
|
||||
|
||||
test('整體驗收段落也勾得到,各勾各的', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code } = await run(['--tick', '- [ ] 待辦解析成巢狀結構', '--section', '整體驗收'], stub);
|
||||
|
||||
assert.equal(code, 0);
|
||||
const body = patchOf(stub).body.body;
|
||||
const todo = body.slice(body.indexOf('## 待辦'), body.indexOf('## 整體驗收'));
|
||||
assert.match(todo, /- \[ \] 待辦解析成巢狀結構/, '待辦那一行不該被動到');
|
||||
assert.match(body.slice(body.indexOf('## 整體驗收')), /- \[x\] 待辦解析成巢狀結構/);
|
||||
});
|
||||
|
||||
test('--section 指到不存在的段落時報錯,不退回掃全文', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(['--tick', '- [ ] 解析九個段落', '--section', '沒有這一段'], stub);
|
||||
|
||||
assert.equal(json.error.code, 'SECTION_NOT_FOUND');
|
||||
});
|
||||
|
||||
test('沒給 --section 時掃全文,但圍欄照樣不算', async (t) => {
|
||||
const body = '## 待辦\n\n```\n- [ ] 圍欄裡的假待辦\n```\n\n- [ ] 真正的待辦\n';
|
||||
const stub = await withStub(t, {}, { body });
|
||||
|
||||
const { code } = await run(['--tick', '- [ ] 圍欄裡的假待辦'], stub);
|
||||
|
||||
assert.equal(code, 1, '圍欄裡的行不是內容,找不到才對');
|
||||
assert.equal(patchOf(stub), undefined);
|
||||
});
|
||||
|
||||
// ── 抽取端與勾選端要對得上 ─────────────────────────────────────────
|
||||
|
||||
test('方框後面沒有空白也勾得到:抽取端收得下的,勾選端就要收得下', async (t) => {
|
||||
// parseChecklistItem 的文法允許 `- [ ]甲`,wp-extract 會照樣交出它的 raw;
|
||||
// 勾選端若比抽取端嚴格,正本那句「一律用 wp-extract 給的 raw」就變成做不到的事
|
||||
const body = '## 待辦\n\n- [ ]沒有空白的那一項\n';
|
||||
const stub = await withStub(t, {}, { body });
|
||||
|
||||
const { code, json } = await run(['--tick', '- [ ]沒有空白的那一項', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
assert.match(patchOf(stub).body.body, /- \[x\]沒有空白的那一項/);
|
||||
});
|
||||
|
||||
test('議題上那一項根本沒有 checkbox 時,錯誤要說清楚而不是謊報已勾過', async (t) => {
|
||||
// wp-extract 會把 `- 忘了寫 checkbox` 當成一項待辦(done:false),
|
||||
// 但那一行沒有方框可以換。這時要說「去議題上補成 checkbox」,不能回報「已經勾過」
|
||||
const body = '## 待辦\n\n- 忘了寫 checkbox 的待辦\n';
|
||||
const stub = await withStub(t, {}, { body });
|
||||
|
||||
const { code, json } = await run(['--tick', '- 忘了寫 checkbox 的待辦', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'NOT_A_CHECKBOX');
|
||||
assert.match(json.error.message, /補/, '要告訴使用者去議題上把它補成 checkbox');
|
||||
});
|
||||
|
||||
// ── 與既有欄位共存 ─────────────────────────────────────────────────
|
||||
|
||||
test('--tick 可以和別的欄位一起送,共用同一個 PATCH', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}/milestones`]: { status: 200, body: [{ id: 3, title: '第一階段' }] },
|
||||
});
|
||||
|
||||
const { code } = await run(
|
||||
['--tick', '- [ ] 解析九個段落', '--section', '待辦', '--milestone', '第一階段'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(code, 0);
|
||||
const patch = patchOf(stub);
|
||||
assert.match(patch.body.body, /- \[x\] 解析九個段落/);
|
||||
assert.equal(patch.body.milestone, 3);
|
||||
});
|
||||
|
||||
test('什麼都沒指定時仍然報 NOTHING_TO_UPDATE', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
assert.equal(json.error.code, 'NOTHING_TO_UPDATE');
|
||||
assert.match(json.error.message, /--tick/, '新欄位也要列進可用清單');
|
||||
});
|
||||
|
||||
// ── --dry-run ─────────────────────────────────────────────────────
|
||||
|
||||
test('--dry-run 印出改完的 body,但不寫進去', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run(
|
||||
['--tick', '- [ ] 解析九個段落', '--section', '待辦', '--dry-run'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.dryRun, true);
|
||||
assert.match(json.data.requests[0].body.body, /- \[x\] 解析九個段落/);
|
||||
assert.equal(patchOf(stub), undefined);
|
||||
});
|
||||
|
||||
test('--dry-run 在已經勾過時要說「實跑不會發任何請求」', async (t) => {
|
||||
// 試跑印出一個 PATCH、實跑卻什麼都不送,是最難查的那種落差
|
||||
const body = withTodoLine('- [ ] 解析九個段落', '- [x] 解析九個段落');
|
||||
const stub = await withStub(t, {}, { body });
|
||||
|
||||
const { json } = await run(
|
||||
['--tick', '- [ ] 解析九個段落', '--section', '待辦', '--dry-run'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(json.data.已經勾過, true);
|
||||
assert.deepEqual(json.data.requests, [], '沒有東西要改,預告的請求就該是空的');
|
||||
});
|
||||
|
||||
test('--dry-run 遇到分不清的 raw 一樣報錯,不會等到實跑才發現', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(['--tick', ' - [ ] 加上測試', '--section', '待辦', '--dry-run'], stub);
|
||||
|
||||
assert.equal(json.error.code, 'RAW_AMBIGUOUS');
|
||||
});
|
||||
|
||||
// ── CRLF 的 body ───────────────────────────────────────────────────
|
||||
|
||||
test('CRLF 的 body 也勾得到,行尾的 \\r 不被吃掉', async (t) => {
|
||||
// 議題只要在 Gitea 網頁上被編輯過就是 CRLF;wp-extract 交出的 raw 會連 \r 一起帶著
|
||||
const body = BODY.replace(/\n/g, '\r\n');
|
||||
const stub = await withStub(t, {}, { body });
|
||||
|
||||
const { code } = await run(['--tick', '- [ ] 解析九個段落\r', '--section', '待辦'], stub);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.match(patchOf(stub).body.body, /- \[x\] 解析九個段落\r\n/);
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { runScript } from './helpers/run-script.js';
|
||||
import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js';
|
||||
import { healthyRoutes, stubEnv as envFor, withStubGitea, patchOf } from './helpers/stub-gitea.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
const INDEX = 12;
|
||||
@@ -39,7 +39,6 @@ const run = (args, stub) =>
|
||||
env: envFor(stub),
|
||||
});
|
||||
|
||||
const patchOf = (stub) => stub.requests.find((r) => r.method === 'PATCH' && !r.path.endsWith('/0'));
|
||||
|
||||
// ── Milestone ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -132,7 +131,24 @@ test('估算沒有變時不重寫 body', async (t) => {
|
||||
|
||||
await run(['--estimate-days', '3'], stub);
|
||||
|
||||
assert.equal('body' in patchOf(stub).body, false, '沒變就不該把 body 塞進 PATCH');
|
||||
assert.equal(
|
||||
patchOf(stub),
|
||||
undefined,
|
||||
'沒有任何欄位要改就整個 PATCH 都不發:空的 PATCH 會把議題的 updated_at 推新,'
|
||||
+ '在列表上浮起來像是有人動過',
|
||||
);
|
||||
});
|
||||
|
||||
test('有別的欄位要改時照樣發 PATCH,但沒變的 body 不跟著被重寫', async (t) => {
|
||||
const stub = await withStub(t, {}, {
|
||||
body: '## 關聯\n\n需求議題:#1\n估算人天:3\n',
|
||||
});
|
||||
|
||||
await run(['--estimate-days', '3', '--milestone', '第一階段'], stub);
|
||||
|
||||
const patch = patchOf(stub);
|
||||
assert.equal(patch.body.milestone, 3);
|
||||
assert.equal('body' in patch.body, false, '估算沒變,body 就不該被塞進去');
|
||||
});
|
||||
|
||||
test('人天必須是正數', async (t) => {
|
||||
@@ -305,7 +321,7 @@ test('連結沒變時不重寫 body', async (t) => {
|
||||
|
||||
await run(['--overview-url', 'https://example.com/a'], stub);
|
||||
|
||||
assert.equal('body' in patchOf(stub).body, false);
|
||||
assert.equal(patchOf(stub), undefined, '連結沒變、也沒有別的欄位要改,就不發 PATCH');
|
||||
});
|
||||
|
||||
test('不是網址時擋在打 Gitea 之前', async (t) => {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* 這一支是唯一直接 import lib 的測試,其餘一律走子行程的 CLI 邊界。
|
||||
* 直接 import lib 的兩支測試之一(另一支是 worktree-path),其餘一律走子行程的 CLI 邊界。
|
||||
*
|
||||
* 理由:冪等查重與 git 執行點是 lib 對「其他腳本」公開的契約,但本工作包只交付
|
||||
* labels-list(讀取型、不碰 git、不需查重),CLI 邊界上還沒有消費者。等 #4 的
|
||||
* issue-create 與 #10 的 branch-prep 落地後,它們的 CLI 測試才是這兩件事的主場,
|
||||
* 屆時這支可以縮小或移除。在那之前直接測 export,好過讓契約完全沒有測試。
|
||||
* 理由:冪等查重與 git 執行點是 lib 對「其他腳本」公開的契約,而 issue-create 與
|
||||
* branch-prep 落地之後,它們的 CLI 測試才是這兩件事的主場——這一支已經是備位的,
|
||||
* 留著是因為直接測 export 仍比讓契約完全沒有測試好。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
@@ -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, /網頁是補充,不是取代/);
|
||||
});
|
||||
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* 讀 PR 上的三類留言。
|
||||
*
|
||||
* 三類分散在三個端點,漏掉任何一類就會有 reviewer 的意見沒被處理——而那正是這一段
|
||||
* 存在的理由。所以測試的重點是「三類都讀到」與「每一則都說得出它能不能被標記」。
|
||||
*
|
||||
* 「已處理」在三類上的機制不同:一般留言與 review 總評看**自己打的** `+1` reaction,
|
||||
* 行內留言看它有沒有被 resolve。總評的 reaction 掛在它的 issue comment id 上,
|
||||
* 不是 review id——兩個 id 不同命名空間,弄錯會 404。
|
||||
*/
|
||||
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 = 45;
|
||||
|
||||
/**
|
||||
* 組出一份假的 PR 留言現場。
|
||||
* @param {object} options general/reviews 各自的內容
|
||||
*/
|
||||
function routes(overrides = {}, options = {}) {
|
||||
const {
|
||||
general = [],
|
||||
reviews = [],
|
||||
pull = { number: INDEX, title: 'feat/pr-comments/main', html_url: `https://x/${INDEX}`, state: 'open' },
|
||||
isPull = true,
|
||||
} = options;
|
||||
|
||||
const base = healthyRoutes(REPO, {
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
|
||||
status: 200,
|
||||
body: { ...pull, ...(isPull ? { pull_request: { merged: false } } : {}) },
|
||||
},
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: {
|
||||
status: 200,
|
||||
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,
|
||||
body: reviews.map((r, i) => ({
|
||||
id: 200 + i,
|
||||
body: r.body ?? '',
|
||||
state: r.state ?? 'COMMENT',
|
||||
user: { login: r.user ?? 'reviewer' },
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
general.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' } })),
|
||||
};
|
||||
});
|
||||
reviews.forEach((r, i) => {
|
||||
// 總評那一則的 reaction 掛在它的 issue comment id 上
|
||||
base[`GET /api/v1/repos/${REPO}/issues/comments/${400 + i}/reactions`] = {
|
||||
status: 200,
|
||||
body: (r.reactions ?? []).map((content) => ({ content, user: { login: 'tester' } })),
|
||||
};
|
||||
base[`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews/${200 + i}/comments`] = {
|
||||
status: 200,
|
||||
body: (r.comments ?? []).map((c, j) => ({
|
||||
id: 300 + i * 10 + j,
|
||||
body: c.body,
|
||||
path: c.path,
|
||||
position: c.position ?? 1,
|
||||
diff_hunk: c.diff ?? '@@ -1 +1 @@',
|
||||
user: { login: c.user ?? r.user ?? 'reviewer' },
|
||||
resolver: c.resolved ? { login: 'someone' } : null,
|
||||
})),
|
||||
};
|
||||
});
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options));
|
||||
|
||||
const run = (args, stub) =>
|
||||
runScript('pr-comments.js', ['--repo', REPO, '--index', String(INDEX), ...args], {
|
||||
env: envFor(stub),
|
||||
});
|
||||
|
||||
/** 一份三類俱全的現場 */
|
||||
const FULL = {
|
||||
general: [
|
||||
{ body: '整體方向沒問題,但命名再想想。', reactions: [] },
|
||||
{ body: '這個我已經處理過了。', reactions: ['+1'] },
|
||||
],
|
||||
reviews: [
|
||||
{
|
||||
body: '大致可以,兩個地方要改。',
|
||||
state: 'REQUEST_CHANGES',
|
||||
comments: [
|
||||
{ body: '這裡少了錯誤處理。', path: 'scripts/claim.js', position: 12 },
|
||||
{ body: '這行可以刪掉。', path: 'scripts/claim.js', position: 30, resolved: true },
|
||||
],
|
||||
},
|
||||
{ body: '', state: 'APPROVED', comments: [{ body: '順手提一下拼字。', path: 'README.md', position: 3 }] },
|
||||
],
|
||||
};
|
||||
|
||||
// ── 三類都要讀到 ───────────────────────────────────────────────────
|
||||
|
||||
test('一般留言、review 總評、行內留言三類都讀得到', async (t) => {
|
||||
const stub = await withStub(t, {}, FULL);
|
||||
|
||||
const { code, json } = await run([], stub);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
const kinds = json.data.留言.map((c) => c.類型);
|
||||
assert.equal(kinds.filter((k) => k === '一般').length, 2);
|
||||
assert.equal(kinds.filter((k) => k === '總評').length, 1, '只有 body 非空的 review 算總評');
|
||||
assert.equal(kinds.filter((k) => k === '行內').length, 3);
|
||||
});
|
||||
|
||||
test('body 是空的 review 不算總評:那是純粹的行內留言容器', async (t) => {
|
||||
const stub = await withStub(t, {}, FULL);
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
const 總評 = json.data.留言.filter((c) => c.類型 === '總評');
|
||||
assert.deepEqual(總評.map((c) => c.內容), ['大致可以,兩個地方要改。']);
|
||||
});
|
||||
|
||||
test('行內留言帶著檔案、行號與 diff 片段,agent 才看得懂在說哪裡', async (t) => {
|
||||
const stub = await withStub(t, {}, FULL);
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
const 行內 = json.data.留言.find((c) => c.類型 === '行內');
|
||||
assert.equal(行內.檔案, 'scripts/claim.js');
|
||||
assert.equal(行內.行, 12);
|
||||
assert.match(行內.diff, /@@/);
|
||||
});
|
||||
|
||||
test('每一則都帶 id 與作者', async (t) => {
|
||||
const stub = await withStub(t, {}, FULL);
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
for (const comment of json.data.留言) {
|
||||
assert.equal(typeof comment.id, 'number', JSON.stringify(comment));
|
||||
assert.equal(typeof comment.作者, 'string');
|
||||
}
|
||||
});
|
||||
|
||||
// ── 已處理的判斷:三類各有各的機制 ─────────────────────────────────
|
||||
|
||||
test('一般留言看 +1 reaction', async (t) => {
|
||||
const stub = await withStub(t, {}, FULL);
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
const 一般 = json.data.留言.filter((c) => c.類型 === '一般');
|
||||
assert.deepEqual(一般.map((c) => c.已處理), [false, true]);
|
||||
});
|
||||
|
||||
test('行內留言看它有沒有被 resolve', async (t) => {
|
||||
const stub = await withStub(t, {}, FULL);
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
const 行內 = json.data.留言.filter((c) => c.類型 === '行內');
|
||||
assert.deepEqual(行內.map((c) => c.已處理), [false, true, false]);
|
||||
});
|
||||
|
||||
test('三類都標記得了:總評用它在 issue comment 表裡的那一份', async (t) => {
|
||||
// Gitea 的 review 總評在 issue comment 表裡也有一份,reaction 掛在那個 id 上。
|
||||
// 用 review 自己的 id 去打 reaction 會 404——兩個 id 不同命名空間。
|
||||
const stub = await withStub(t, {}, FULL);
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
for (const comment of json.data.留言) {
|
||||
assert.equal(comment.可標記, true, `${comment.類型}應該標記得了`);
|
||||
}
|
||||
});
|
||||
|
||||
test('總評的 id 是它在 issue comment 表裡的 id,不是 review id', async (t) => {
|
||||
const stub = await withStub(t, {}, FULL);
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
const 總評 = json.data.留言.find((c) => c.類型 === '總評');
|
||||
assert.equal(總評.id, 400, 'timeline 給的 comment id');
|
||||
assert.equal(總評.review, 200, 'review id 另外帶著,行內留言要靠它查');
|
||||
});
|
||||
|
||||
test('總評的已處理看它自己那則 comment 的 +1', async (t) => {
|
||||
const stub = await withStub(t, {}, {
|
||||
reviews: [
|
||||
{ body: '這則處理過了', reactions: ['+1'] },
|
||||
{ body: '這則還沒', reactions: [] },
|
||||
],
|
||||
});
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
assert.deepEqual(json.data.留言.map((c) => c.已處理), [true, false]);
|
||||
});
|
||||
|
||||
test('別人打的 +1 不算已處理:那是「我同意」,不是「我處理過了」', async (t) => {
|
||||
// reviewer 對自己的留言按讚很常見;當成已處理的話,那一則會被靜靜跳過
|
||||
const stub = await withStub(t, {}, {
|
||||
general: [
|
||||
{ body: '自己打的', reactions: ['+1'], reactedBy: 'tester' },
|
||||
{ body: '別人打的', reactions: ['+1'], reactedBy: 'reviewer' },
|
||||
],
|
||||
});
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
assert.deepEqual(json.data.留言.map((c) => c.已處理), [true, false]);
|
||||
});
|
||||
|
||||
test('未處理數只算還沒處理的,且與明細對得上', async (t) => {
|
||||
const stub = await withStub(t, {}, FULL);
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
assert.equal(json.data.未處理數, json.data.留言.filter((c) => !c.已處理).length);
|
||||
assert.equal(json.data.未處理數, 4);
|
||||
});
|
||||
|
||||
test('留在刪除行的行內留言,位置在 original_position 上', async (t) => {
|
||||
// Gitea 只填 position 與 original_position 其中一個:新檔那一側用 position,
|
||||
// 舊檔(被刪掉的行)那一側用 original_position。只讀 position 會得到 undefined。
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews/200/comments`]: {
|
||||
status: 200,
|
||||
body: [{
|
||||
id: 350,
|
||||
body: '這一行為什麼刪掉?',
|
||||
path: 'scripts/old.js',
|
||||
position: 0,
|
||||
original_position: 7,
|
||||
diff_hunk: '@@ -7 +0 @@',
|
||||
user: { login: 'r' },
|
||||
resolver: null,
|
||||
}],
|
||||
},
|
||||
}, { reviews: [{ body: '', comments: [] }] });
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
const 行內 = json.data.留言.find((c) => c.類型 === '行內');
|
||||
assert.equal(行內.行, 7);
|
||||
assert.equal(行內.側, '舊', '回覆時要知道它在哪一側,否則位置會送錯欄位');
|
||||
});
|
||||
|
||||
test('一般的行內留言在新檔那一側', async (t) => {
|
||||
const stub = await withStub(t, {}, FULL);
|
||||
|
||||
const 行內 = (await run([], stub)).json.data.留言.find((c) => c.類型 === '行內');
|
||||
|
||||
assert.equal(行內.側, '新');
|
||||
});
|
||||
|
||||
// ── 分頁:三類都要讀完 ─────────────────────────────────────────────
|
||||
|
||||
test('review 逐頁讀完:超過一頁就漏掉總評與行內留言', async (t) => {
|
||||
// 這個站台的預設頁大小是 30;沒分頁的話,第 31 個 review 之後整批消失,
|
||||
// 而「不漏掉任何一則」正是這個指令存在的理由
|
||||
const page1 = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: 600 + i, body: `第 ${i} 則總評`, state: 'COMMENT', user: { login: 'r' },
|
||||
}));
|
||||
const page2 = [{ id: 700, body: '最後一則總評', state: 'COMMENT', user: { login: 'r' } }];
|
||||
const extra = {};
|
||||
for (const r of [...page1, ...page2]) {
|
||||
extra[`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews/${r.id}/comments`] = { status: 200, body: [] };
|
||||
}
|
||||
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: (req) => ({
|
||||
status: 200,
|
||||
body: req.query.page === '1' ? page1 : page2,
|
||||
}),
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: (req) => ({
|
||||
status: 200,
|
||||
body: (req.query.page === '1' ? page1 : page2).map((r) => ({
|
||||
id: r.id + 5000, type: 'review', review_id: r.id, body: r.body, user: r.user,
|
||||
})),
|
||||
}),
|
||||
...extra,
|
||||
...Object.fromEntries([...page1, ...page2].map((r) => [
|
||||
`GET /api/v1/repos/${REPO}/issues/comments/${r.id + 5000}/reactions`, { status: 200, body: [] },
|
||||
])),
|
||||
}, {});
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
assert.equal(json.data.留言.length, 51);
|
||||
assert.equal(json.data.留言.at(-1).內容, '最後一則總評');
|
||||
});
|
||||
|
||||
// ── PENDING 的 review 還沒送出 ─────────────────────────────────────
|
||||
|
||||
test('PENDING 的 review 不算數:它還沒送出,reviewer 自己也看不到', async (t) => {
|
||||
const stub = await withStub(t, {}, {
|
||||
reviews: [
|
||||
{ body: '寫到一半的草稿', state: 'PENDING', comments: [{ body: '草稿裡的行內', path: 'a.js' }] },
|
||||
{ body: '送出來的', state: 'COMMENT' },
|
||||
],
|
||||
});
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
assert.deepEqual(json.data.留言.map((c) => c.內容), ['送出來的']);
|
||||
});
|
||||
|
||||
// ── 空留言與系統事件 ───────────────────────────────────────────────
|
||||
|
||||
test('內容是空的一般留言不算:那多半是狀態變更的系統紀錄', async (t) => {
|
||||
const stub = await withStub(t, {}, {
|
||||
general: [{ body: '' }, { body: ' ' }, { body: '真的留言' }],
|
||||
});
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
assert.deepEqual(json.data.留言.map((c) => c.內容), ['真的留言']);
|
||||
});
|
||||
|
||||
test('完全沒有留言時回空陣列,不報錯', async (t) => {
|
||||
const stub = await withStub(t, {}, {});
|
||||
|
||||
const { code, json } = await run([], stub);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.deepEqual(json.data.留言, []);
|
||||
assert.equal(json.data.未處理數, 0);
|
||||
});
|
||||
|
||||
// ── PR 本身 ───────────────────────────────────────────────────────
|
||||
|
||||
test('帶出 PR 的識別資訊,讓回報不必再查一次', async (t) => {
|
||||
const stub = await withStub(t, {}, FULL);
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
assert.equal(json.data.index, INDEX);
|
||||
assert.equal(json.data.title, 'feat/pr-comments/main');
|
||||
assert.equal(json.data.url, `https://x/${INDEX}`);
|
||||
assert.equal(json.data.state, 'open');
|
||||
});
|
||||
|
||||
test('議題不存在時回可區分的錯誤碼', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 404, body: { message: 'not found' } },
|
||||
}, FULL);
|
||||
|
||||
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('純議題也讀得到:先讀 issue 再決定要不要翻 review', async (t) => {
|
||||
// 每個 PR 都是議題,議題不一定是 PR。先打 /pulls 的話,純議題會 404,
|
||||
// 而 /sdlc-sync 的輸入正是純議題——整個流程在讀到第一則留言之前就斷了
|
||||
const stub = await withStub(t, {}, {
|
||||
isPull: false,
|
||||
general: [{ body: '這顆議題上的決策', reactions: [] }],
|
||||
reviews: [],
|
||||
});
|
||||
|
||||
const { code, json } = await run([], stub);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
assert.equal(json.data.類型, '議題');
|
||||
assert.deepEqual(json.data.留言.map((c) => c.內容), ['這顆議題上的決策']);
|
||||
assert.equal(
|
||||
stub.requests.some((r) => r.path.includes('/pulls/')),
|
||||
false,
|
||||
'純議題不該去打 PR 的端點',
|
||||
);
|
||||
});
|
||||
|
||||
test('是 PR 時類型標成 PR,並照樣讀 review', async (t) => {
|
||||
const stub = await withStub(t, {}, FULL);
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
assert.equal(json.data.類型, 'PR');
|
||||
assert.ok(json.data.留言.some((c) => c.類型 === '總評'));
|
||||
});
|
||||
|
||||
// ── --dry-run ─────────────────────────────────────────────────────
|
||||
|
||||
test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => {
|
||||
const stub = await withStub(t, {}, FULL);
|
||||
|
||||
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}/timeline`,
|
||||
],
|
||||
);
|
||||
assert.match(json.data.note, /reaction|review/);
|
||||
assert.equal(stub.requests.length, 0);
|
||||
});
|
||||
|
||||
// ── 分頁 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('留言逐頁讀完,不是只讀第一頁', async (t) => {
|
||||
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}/timeline`]: (req) => ({
|
||||
status: 200,
|
||||
body: req.query.page === '1' ? page1 : page2,
|
||||
}),
|
||||
...reactions,
|
||||
}, {});
|
||||
|
||||
const { json } = await run([], stub);
|
||||
|
||||
assert.equal(json.data.留言.length, 51);
|
||||
assert.equal(json.data.留言.at(-1).內容, '最後一則');
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* 回覆一則 PR 留言並標記已處理。
|
||||
*
|
||||
* 「回在 reviewer 原本那一串底下」對三類留言是三件不同的事:
|
||||
* - 行內留言 → 新 review 帶一則指向**同一個檔案與同一行**的留言,Gitea 才會把它
|
||||
* 排在原留言底下。位置抓錯就變成另開一串,reviewer 得自己找對應。
|
||||
* - 一般留言 → PR 的一般留言是平的,沒有串;回覆就是新增一則,並引用原文開頭
|
||||
* 讓人看得出在回誰。
|
||||
* - review 總評 → 沒有 reaction 也沒有 resolve,回覆是唯一能留下的痕跡。
|
||||
*
|
||||
* 所以這一支的測試重點是「回對地方」與「標記用對機制」,而不是回覆的文字內容。
|
||||
*/
|
||||
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 = 45;
|
||||
/** 行內留言的 id 與它所在的位置 */
|
||||
const INLINE = { id: 301, path: 'scripts/claim.js', position: 12, commit: 'abc123' };
|
||||
const GENERAL = { id: 101, body: '命名再想想。' };
|
||||
/** 總評:review 表的 id 與它在 issue comment 表裡那一份的 id */
|
||||
const REVIEW = { id: 201, commentId: 401, body: '大致可以,兩個地方要改。' };
|
||||
|
||||
function routes(overrides = {}) {
|
||||
return healthyRoutes(REPO, {
|
||||
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: {
|
||||
status: 200,
|
||||
body: [{ id: REVIEW.id, body: REVIEW.body, state: 'REQUEST_CHANGES', user: { login: 'r' } }],
|
||||
},
|
||||
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews/${REVIEW.id}/comments`]: {
|
||||
status: 200,
|
||||
body: [{
|
||||
id: INLINE.id,
|
||||
body: '這裡少了錯誤處理。',
|
||||
path: INLINE.path,
|
||||
position: INLINE.position,
|
||||
commit_id: INLINE.commit,
|
||||
user: { login: 'r' },
|
||||
resolver: null,
|
||||
}],
|
||||
},
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: {
|
||||
status: 200,
|
||||
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: {} },
|
||||
[`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 } },
|
||||
[`POST /api/v1/repos/${REPO}/pulls/comments/${INLINE.id}/resolve`]: { status: 200, body: {} },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
const withStub = (t, overrides = {}) => withStubGitea(t, routes(overrides));
|
||||
|
||||
const run = (args, stub) =>
|
||||
runScript('pr-reply.js', ['--repo', REPO, '--index', String(INDEX), ...args], {
|
||||
env: envFor(stub),
|
||||
});
|
||||
|
||||
const posted = (stub) =>
|
||||
stub.requests.filter((r) => r.method === 'POST' && !r.path.endsWith('/issues/0'));
|
||||
|
||||
// ── 行內留言:回在同一個位置 ───────────────────────────────────────
|
||||
|
||||
test('回覆行內留言時,新留言指向同一個檔案與同一行', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run(
|
||||
['--comment', String(INLINE.id), '--kind', 'inline', '--body', '已補上錯誤處理。'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
const review = posted(stub).find((r) => r.path.endsWith('/reviews'));
|
||||
assert.deepEqual(review.body.comments, [
|
||||
{ path: INLINE.path, new_position: INLINE.position, body: '已補上錯誤處理。' },
|
||||
]);
|
||||
assert.equal(review.body.event, 'COMMENT', '回覆不該順手把 PR 標成通過或要求變更');
|
||||
assert.equal(
|
||||
review.body.commit_id,
|
||||
INLINE.commit,
|
||||
'PR 之後又推了新 commit 時,行號在新 commit 上指的是別的程式碼',
|
||||
);
|
||||
});
|
||||
|
||||
test('留在刪除行的留言要送 old_position,不是 new_position', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews/${REVIEW.id}/comments`]: {
|
||||
status: 200,
|
||||
body: [{
|
||||
id: INLINE.id,
|
||||
body: '這一行為什麼刪掉?',
|
||||
path: 'scripts/old.js',
|
||||
position: 0,
|
||||
original_position: 7,
|
||||
commit_id: INLINE.commit,
|
||||
user: { login: 'r' },
|
||||
resolver: null,
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
const { code, json } = await run(
|
||||
['--comment', String(INLINE.id), '--kind', 'inline', '--body', '因為它已經沒有呼叫端了。'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
assert.deepEqual(posted(stub).find((r) => r.path.endsWith('/reviews')).body.comments, [
|
||||
{ path: 'scripts/old.js', old_position: 7, body: '因為它已經沒有呼叫端了。' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('行內留言回覆完會被 resolve', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(
|
||||
['--comment', String(INLINE.id), '--kind', 'inline', '--body', '已修正。'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.ok(posted(stub).some((r) => r.path.endsWith(`/pulls/comments/${INLINE.id}/resolve`)));
|
||||
assert.equal(json.data.已標記, true);
|
||||
assert.equal(json.data.標記方式, 'resolve');
|
||||
});
|
||||
|
||||
test('先回覆再標記:標記是「這一則處理完了」的結論', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
await run(['--comment', String(INLINE.id), '--kind', 'inline', '--body', '已修正。'], stub);
|
||||
|
||||
const paths = posted(stub).map((r) => r.path);
|
||||
assert.ok(
|
||||
paths.indexOf(`/api/v1/repos/${REPO}/pulls/${INDEX}/reviews`)
|
||||
< paths.indexOf(`/api/v1/repos/${REPO}/pulls/comments/${INLINE.id}/resolve`),
|
||||
);
|
||||
});
|
||||
|
||||
test('回覆失敗時不標記:沒回就標記等於謊稱處理過', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`POST /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: { status: 422, body: { message: 'bad position' } },
|
||||
});
|
||||
|
||||
const { code } = await run(
|
||||
['--comment', String(INLINE.id), '--kind', 'inline', '--body', '已修正。'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(posted(stub).some((r) => r.path.endsWith('/resolve')), false);
|
||||
});
|
||||
|
||||
test('找不到那則行內留言時回可區分的錯誤碼', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(
|
||||
['--comment', '99999', '--kind', 'inline', '--body', '已修正。'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(json.error.code, 'COMMENT_NOT_FOUND');
|
||||
assert.deepEqual(posted(stub), []);
|
||||
});
|
||||
|
||||
// ── 一般留言:平的,回覆要引用得出在回誰 ───────────────────────────
|
||||
|
||||
test('回覆一般留言時新增一則留言,並引用原文讓人看得出在回誰', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run(
|
||||
['--comment', String(GENERAL.id), '--kind', 'general', '--body', '已改名為 claimWorkPackage。'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
const comment = posted(stub).find((r) => r.path.endsWith(`/issues/${INDEX}/comments`));
|
||||
assert.match(comment.body.body, /已改名為 claimWorkPackage。/);
|
||||
assert.match(comment.body.body, />.*命名再想想/s, 'PR 的一般留言是平的,要引用才看得出在回誰');
|
||||
});
|
||||
|
||||
test('一般留言用 +1 標記,不是 resolve', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(
|
||||
['--comment', String(GENERAL.id), '--kind', 'general', '--body', '已處理。'],
|
||||
stub,
|
||||
);
|
||||
|
||||
const reaction = posted(stub).find((r) => r.path.endsWith('/reactions'));
|
||||
assert.deepEqual(reaction.body, { content: '+1' });
|
||||
assert.equal(json.data.標記方式, 'reaction');
|
||||
});
|
||||
|
||||
// ── review 總評:標不了,但仍要回 ─────────────────────────────────
|
||||
|
||||
test('回覆 review 總評並用它在 issue comment 表裡的 id 打 +1', async (t) => {
|
||||
// 總評標得了,只是 reaction 要掛在另一個 id 上;用 review id 會 404
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run(
|
||||
['--comment', String(REVIEW.id), '--kind', 'review', '--body', '兩處都已修正。'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
assert.equal(json.data.已標記, true);
|
||||
assert.equal(json.data.標記方式, 'reaction');
|
||||
const reaction = posted(stub).find((r) => r.path.includes('/reactions'));
|
||||
assert.ok(
|
||||
reaction.path.endsWith(`/issues/comments/${REVIEW.commentId}/reactions`),
|
||||
`reaction 要打在 ${REVIEW.commentId} 上,不是 review 的 ${REVIEW.id}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('總評在 timeline 上對不到那一份時,如實說標不了而不是硬打', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: { status: 200, body: [] },
|
||||
});
|
||||
|
||||
const { code, json } = await run(
|
||||
['--comment', String(REVIEW.id), '--kind', 'review', '--body', '已修正。'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
assert.equal(json.data.已標記, false);
|
||||
assert.equal(json.data.標記方式, null);
|
||||
assert.match(json.data.note ?? '', /標記/);
|
||||
assert.equal(posted(stub).some((r) => r.path.includes('/reactions')), false);
|
||||
});
|
||||
|
||||
test('輸出的欄位形狀固定:用不到的欄位是 null,不是不存在', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const inline = await run(
|
||||
['--comment', String(INLINE.id), '--kind', 'inline', '--body', '已修正。'], stub);
|
||||
const stub2 = await withStub(t);
|
||||
const general = await run(
|
||||
['--comment', String(GENERAL.id), '--kind', 'general', '--body', '已修正。'], stub2);
|
||||
|
||||
for (const { json } of [inline, general]) {
|
||||
for (const key of ['已標記', '標記方式', '位置', 'note']) {
|
||||
assert.ok(key in json.data, `輸出少了 ${key}:下游不該為此多寫一種分支`);
|
||||
}
|
||||
}
|
||||
assert.equal(general.json.data.位置, null, '一般留言沒有位置,但欄位要在');
|
||||
});
|
||||
|
||||
// ── 輸入 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('--kind 不是三類之一時擋下,並列出可用的', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(
|
||||
['--comment', String(GENERAL.id), '--kind', '行內', '--body', '已處理。'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(json.error.code, 'BAD_KIND');
|
||||
assert.match(json.error.message, /inline/);
|
||||
});
|
||||
|
||||
test('回覆內容是空的時候擋下', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(
|
||||
['--comment', String(GENERAL.id), '--kind', 'general', '--body', ' '],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(json.error.code, 'EMPTY_REPLY');
|
||||
assert.deepEqual(posted(stub), []);
|
||||
});
|
||||
|
||||
// ── --dry-run ─────────────────────────────────────────────────────
|
||||
|
||||
test('--dry-run 印出將發出的回覆與標記,且不張貼', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { code, json } = await run(
|
||||
['--comment', String(INLINE.id), '--kind', 'inline', '--body', '已修正。', '--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}/pulls/${INDEX}/reviews`,
|
||||
`POST /repos/${REPO}/pulls/comments/${INLINE.id}/resolve`,
|
||||
],
|
||||
);
|
||||
assert.deepEqual(posted(stub), [], '預覽不得張貼');
|
||||
});
|
||||
|
||||
test('--dry-run 照樣查得出位置:位置錯了不該等到實跑才發現', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(
|
||||
['--comment', String(INLINE.id), '--kind', 'inline', '--body', '已修正。', '--dry-run'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.deepEqual(json.data.requests[0].body.comments, [
|
||||
{ path: INLINE.path, new_position: INLINE.position, body: '已修正。' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('--dry-run 對總評印出回覆與 reaction 兩步', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(
|
||||
['--comment', String(REVIEW.id), '--kind', 'review', '--body', '已修正。', '--dry-run'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
json.data.requests.map((r) => `${r.method} ${r.path}`),
|
||||
[
|
||||
`POST /repos/${REPO}/issues/${INDEX}/comments`,
|
||||
`POST /repos/${REPO}/issues/comments/${REVIEW.commentId}/reactions`,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('PENDING 的 review 回不了:reviewer 自己都還沒送出', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: {
|
||||
status: 200,
|
||||
body: [{ id: REVIEW.id, body: '草稿', state: 'PENDING', user: { login: 'r' } }],
|
||||
},
|
||||
});
|
||||
|
||||
const { json } = await run(
|
||||
['--comment', String(REVIEW.id), '--kind', 'review', '--body', '已修正。'],
|
||||
stub,
|
||||
);
|
||||
|
||||
assert.equal(json.error.code, 'COMMENT_NOT_FOUND');
|
||||
assert.deepEqual(posted(stub), []);
|
||||
});
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* 回報 PR 現況,並在它結束時清掉工作樹。
|
||||
*
|
||||
* 這一支的價值在**四種 PR 狀態各自的處置**:merged 與 closed 是終止狀態,工作樹清掉;
|
||||
* open 繼續監看;draft 尤其要盯住——被退回草稿代表還要繼續改,這時候那棵工作樹更需要
|
||||
* 留著,清掉它等於把人做到一半的環境收走。
|
||||
*
|
||||
* 另外兩件事各有測試:建議動作是**列舉值**(呼叫端要程式化判斷,不是去讀一段文字),
|
||||
* 以及清理的守門(有未提交變更就擋下,絕不 `--force`)。
|
||||
*
|
||||
* 一次性、無狀態:不與上次的結果比較,也不讀寫任何游標或狀態檔——「已處理」的判定
|
||||
* 基準是 Gitea 上的 `+1` 與 resolve,現況快照本身就足以回答「還有沒有事要做」。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { runScript, tmpRoot } from './helpers/run-script.js';
|
||||
import { makeTempRepoWithRemote } from './helpers/temp-repo.js';
|
||||
import { healthyRoutes, stubEnv, withStubGitea } from './helpers/stub-gitea.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
const INDEX = 46;
|
||||
const SLUG = 'pr-watch-and-cleanup';
|
||||
const BRANCH = `feat/${SLUG}/main`;
|
||||
|
||||
/**
|
||||
* 一顆 PR 的現場:狀態由 options 決定,留言預設沒有。
|
||||
* 一般留言只有在沒有自己打的 `+1` 時才算未處理。
|
||||
*/
|
||||
function routes({ state = 'open', merged = false, draft = false, general = [], head } = {}) {
|
||||
const base = healthyRoutes(REPO, {
|
||||
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}`]: {
|
||||
status: 200,
|
||||
body: {
|
||||
number: INDEX,
|
||||
title: BRANCH,
|
||||
html_url: `https://gitea.jsc.idv.tw/${REPO}/pulls/${INDEX}`,
|
||||
state,
|
||||
merged,
|
||||
draft,
|
||||
// 預設是「PR 還開著」的樣子:head.ref 就是分支名。合併之後 Gitea 會換一種樣子,
|
||||
// 那一種由測試自己指定。
|
||||
head: head ?? { ref: BRANCH, label: BRANCH },
|
||||
},
|
||||
},
|
||||
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: {
|
||||
status: 200,
|
||||
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: [] },
|
||||
});
|
||||
general.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;
|
||||
}
|
||||
|
||||
/** 備好一棵真的工作樹(由 branch-prep 建)與一台假 Gitea */
|
||||
async function withScene(t, pr = {}) {
|
||||
const repo = makeTempRepoWithRemote();
|
||||
t.after(() => repo.cleanup());
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
const home = mkdtempSync(join(tmpRoot, 'home-'));
|
||||
t.after(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
const prep = await runScript(
|
||||
'branch-prep.js',
|
||||
['--repo', REPO, '--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', SLUG],
|
||||
{ env: { TEA_SDLC_HOME: home } },
|
||||
);
|
||||
assert.equal(prep.json.ok, true, prep.json.error?.message);
|
||||
|
||||
const stub = await withStubGitea(t, routes(pr));
|
||||
const run = (args = []) =>
|
||||
runScript('pr-watch.js', ['--repo', REPO, '--index', String(INDEX), ...args], {
|
||||
env: { ...stubEnv(stub), TEA_SDLC_HOME: home },
|
||||
});
|
||||
|
||||
return { repo, home, stub, run, worktree: prep.json.data.worktree };
|
||||
}
|
||||
|
||||
// ── PR 狀態到處置的對照表 ─────────────────────────────────────────
|
||||
|
||||
const DISPOSITION = [
|
||||
{
|
||||
name: 'open:繼續監看,工作樹留著',
|
||||
pr: { state: 'open' },
|
||||
expected: { state: 'open', terminal: false, cleaned: false, suggestedAction: 'nothing-to-do' },
|
||||
工作樹還在: true,
|
||||
},
|
||||
{
|
||||
name: 'merged:終止,工作樹清掉',
|
||||
pr: { state: 'closed', merged: true },
|
||||
expected: { state: 'merged', terminal: true, cleaned: true, suggestedAction: 'nothing-to-do' },
|
||||
工作樹還在: false,
|
||||
},
|
||||
{
|
||||
name: 'closed:同樣終止,工作樹清掉',
|
||||
pr: { state: 'closed', merged: false },
|
||||
expected: { state: 'closed', terminal: true, cleaned: true, suggestedAction: 'nothing-to-do' },
|
||||
工作樹還在: false,
|
||||
},
|
||||
{
|
||||
name: 'draft:不終止也不清理——退回草稿代表還要繼續改',
|
||||
pr: { state: 'open', draft: true },
|
||||
expected: { state: 'draft', terminal: false, cleaned: false, suggestedAction: 'nothing-to-do' },
|
||||
工作樹還在: true,
|
||||
},
|
||||
];
|
||||
|
||||
for (const { name, pr, expected, 工作樹還在 } of DISPOSITION) {
|
||||
test(`狀態:${name}`, async (t) => {
|
||||
const { run, worktree } = await withScene(t, pr);
|
||||
|
||||
const { code, json } = await run();
|
||||
|
||||
assert.equal(code, 0, json.error?.message);
|
||||
assert.equal(json.data.state, expected.state);
|
||||
assert.equal(json.data.terminal, expected.terminal);
|
||||
assert.equal(json.data.cleaned, expected.cleaned);
|
||||
assert.equal(json.data.suggestedAction, expected.suggestedAction);
|
||||
assert.equal(existsSync(worktree), 工作樹還在);
|
||||
});
|
||||
}
|
||||
|
||||
test('清理只移除工作樹,本機分支留著', async (t) => {
|
||||
const { repo, run, worktree } = await withScene(t, { state: 'closed', merged: true });
|
||||
|
||||
await run();
|
||||
|
||||
assert.equal(existsSync(worktree), false);
|
||||
assert.equal(
|
||||
repo.git('branch', '--list', BRANCH).trim().replace(/^\*?\s*/, ''),
|
||||
BRANCH,
|
||||
'本機分支要留著,之後還能回頭看那段歷史',
|
||||
);
|
||||
});
|
||||
|
||||
// ── 未處理留言 ─────────────────────────────────────────────────────
|
||||
|
||||
test('有未處理留言時建議去跑 sdlc-fix,但不自己執行', async (t) => {
|
||||
const { run, stub } = await withScene(t, { general: [{ body: '這裡少了錯誤處理' }] });
|
||||
|
||||
const { json } = await run();
|
||||
|
||||
assert.equal(json.data.未處理留言數, 1);
|
||||
assert.equal(json.data.suggestedAction, 'run-sdlc-fix');
|
||||
assert.deepEqual(
|
||||
stub.requests.filter((r) => r.method !== 'GET' && !r.path.endsWith('/issues/0')),
|
||||
[],
|
||||
'只通知不動手:監看不該替使用者回覆或標記任何東西',
|
||||
);
|
||||
});
|
||||
|
||||
test('自己打過 +1 的留言算已處理,不再催', async (t) => {
|
||||
const { run } = await withScene(t, {
|
||||
general: [{ body: '這裡少了錯誤處理', reactions: ['+1'], reactedBy: 'tester' }],
|
||||
});
|
||||
|
||||
const { json } = await run();
|
||||
|
||||
assert.equal(json.data.未處理留言數, 0);
|
||||
assert.equal(json.data.suggestedAction, 'nothing-to-do');
|
||||
});
|
||||
|
||||
test('別人打的 +1 不算已處理:那是「我同意」,不是「我處理過了」', async (t) => {
|
||||
const { run } = await withScene(t, {
|
||||
general: [{ body: '這裡少了錯誤處理', reactions: ['+1'], reactedBy: 'reviewer' }],
|
||||
});
|
||||
|
||||
const { json } = await run();
|
||||
|
||||
assert.equal(json.data.未處理留言數, 1);
|
||||
});
|
||||
|
||||
test('draft 上也照樣數留言,繼續監看', async (t) => {
|
||||
const { run, worktree } = await withScene(t, {
|
||||
state: 'open',
|
||||
draft: true,
|
||||
general: [{ body: '這段先別急著合併' }],
|
||||
});
|
||||
|
||||
const { json } = await run();
|
||||
|
||||
assert.equal(json.data.state, 'draft');
|
||||
assert.equal(json.data.suggestedAction, 'run-sdlc-fix');
|
||||
assert.equal(json.data.terminal, false);
|
||||
assert.equal(existsSync(worktree), true, '被退回草稿時更需要那棵工作樹');
|
||||
});
|
||||
|
||||
// ── 清理的守門 ─────────────────────────────────────────────────────
|
||||
|
||||
test('工作樹裡有未提交變更時擋下清理,並報出路徑', async (t) => {
|
||||
const { run, worktree } = await withScene(t, { state: 'closed', merged: true });
|
||||
writeFileSync(join(worktree, 'wip.txt'), '做到一半\n');
|
||||
|
||||
const { code, json } = await run();
|
||||
|
||||
assert.equal(code, 0, '這是一份現況回報,擋下清理不等於整件事失敗');
|
||||
assert.equal(json.data.terminal, true);
|
||||
assert.equal(json.data.cleaned, false);
|
||||
assert.equal(json.data.suggestedAction, 'blocked-dirty');
|
||||
assert.equal(json.data.工作樹.有未提交變更, true);
|
||||
assert.deepEqual(json.data.工作樹.檔案, ['wip.txt']);
|
||||
assert.equal(existsSync(join(worktree, 'wip.txt')), true, '絕不 --force:沒提交的東西救不回來');
|
||||
});
|
||||
|
||||
test('工作樹早就不在時不當成失敗,也不說自己清了', async (t) => {
|
||||
const { repo, run, worktree } = await withScene(t, { state: 'closed', merged: true });
|
||||
repo.git('worktree', 'remove', worktree);
|
||||
|
||||
const { code, json } = await run();
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.工作樹.存在, false);
|
||||
assert.equal(json.data.cleaned, false);
|
||||
assert.equal(json.data.suggestedAction, 'nothing-to-do');
|
||||
});
|
||||
|
||||
test('路徑上是別的 clone 留下的東西時,說出來而不是靜靜跳過', async (t) => {
|
||||
const { repo, run, worktree } = await withScene(t, { state: 'closed', merged: true });
|
||||
repo.git('worktree', 'remove', worktree);
|
||||
mkdirSync(join(worktree, '.git'), { recursive: true });
|
||||
|
||||
const { code, json } = await run();
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.工作樹.是工作樹, false, '.git 是目錄的是獨立 clone,不是工作樹');
|
||||
assert.equal(json.data.cleaned, false);
|
||||
assert.equal(json.data.suggestedAction, 'cleanup', '要人動手,而手動出口會說出確切的原因');
|
||||
assert.equal(existsSync(join(worktree, '.git')), true, '不是我們建的東西就不碰');
|
||||
});
|
||||
|
||||
test('清不掉的路徑,--dry-run 不會預告一行實跑會拒絕的指令', async (t) => {
|
||||
// 試跑印得出漂亮的計畫、實跑卻被擋下來,是最難查的那種落差
|
||||
const { repo, run, worktree } = await withScene(t, { state: 'closed', merged: true });
|
||||
repo.git('worktree', 'remove', worktree);
|
||||
mkdirSync(worktree, { recursive: true });
|
||||
writeFileSync(join(worktree, '別人的東西.txt'), 'x\n');
|
||||
|
||||
const { json } = await run(['--dry-run']);
|
||||
|
||||
assert.deepEqual(json.data.commands, []);
|
||||
assert.equal(json.data.工作樹.是工作樹, false);
|
||||
});
|
||||
|
||||
// ── 合併之後的 head ───────────────────────────────────────────────
|
||||
|
||||
test('來源分支在合併時被刪掉,仍要推導出正確的工作樹並清掉它', async (t) => {
|
||||
// Gitea 在這種情況下把 head.ref 換成 refs/pull/{編號}/head,分支名退到 head.label。
|
||||
// 拿 ref 去推導會算出一條不存在的路徑,然後靜靜回報「沒事要做」——而合併正是唯一
|
||||
// 該動手清理的時機,等於自動清理在真實情況下從來不會成立。
|
||||
const { run, worktree } = await withScene(t, {
|
||||
state: 'closed',
|
||||
merged: true,
|
||||
head: { ref: `refs/pull/${INDEX}/head`, label: BRANCH },
|
||||
});
|
||||
|
||||
const { code, json } = await run();
|
||||
|
||||
assert.equal(code, 0, json.error?.message);
|
||||
assert.equal(json.data.branch, BRANCH, '分支名要取自 head.label');
|
||||
assert.equal(json.data.工作樹.路徑, worktree);
|
||||
assert.equal(json.data.cleaned, true);
|
||||
assert.equal(existsSync(worktree), false);
|
||||
});
|
||||
|
||||
test('fork 來的 PR:head.label 是 owner:branch,只取分支那一段', async (t) => {
|
||||
const { run, worktree } = await withScene(t, {
|
||||
state: 'closed',
|
||||
merged: true,
|
||||
head: { ref: `refs/pull/${INDEX}/head`, label: `someone:${BRANCH}` },
|
||||
});
|
||||
|
||||
const { json } = await run();
|
||||
|
||||
assert.equal(json.data.branch, BRANCH);
|
||||
assert.equal(existsSync(worktree), false, '推導出來的仍是同一條路徑');
|
||||
});
|
||||
|
||||
test('分支名兩邊都取不到時明確中止,不拿空字串去推導路徑', async (t) => {
|
||||
const { run } = await withScene(t, {
|
||||
state: 'closed',
|
||||
merged: true,
|
||||
head: { ref: `refs/pull/${INDEX}/head` },
|
||||
});
|
||||
|
||||
const { code, json } = await run();
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'PULL_HEAD_MISSING');
|
||||
assert.match(json.error.message, /worktree-remove/, '要指出手動出口怎麼指名那一棵');
|
||||
});
|
||||
|
||||
// ── 回報內容 ───────────────────────────────────────────────────────
|
||||
|
||||
test('回報內容含 PR 狀態、未處理留言數與工作樹現況', async (t) => {
|
||||
const { run, worktree } = await withScene(t, { general: [{ body: '一則意見' }] });
|
||||
|
||||
const { json } = await run();
|
||||
|
||||
assert.equal(json.data.index, INDEX);
|
||||
assert.equal(json.data.url, `https://gitea.jsc.idv.tw/${REPO}/pulls/${INDEX}`);
|
||||
assert.equal(json.data.branch, BRANCH, '工作樹是從 PR 的 head 分支推導的,要說出用的是哪一支');
|
||||
assert.equal(json.data.未處理留言數, 1);
|
||||
assert.deepEqual(json.data.工作樹, {
|
||||
路徑: worktree,
|
||||
存在: true,
|
||||
是工作樹: true,
|
||||
有未提交變更: false,
|
||||
檔案: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('建議動作是固定的列舉值,呼叫端才能程式化判斷', async (t) => {
|
||||
const 列舉 = ['run-sdlc-fix', 'cleanup', 'nothing-to-do', 'blocked-dirty'];
|
||||
const { run } = await withScene(t);
|
||||
|
||||
const { json } = await run();
|
||||
|
||||
assert.ok(列舉.includes(json.data.suggestedAction), `不在列舉裡:${json.data.suggestedAction}`);
|
||||
});
|
||||
|
||||
// ── 無狀態 ─────────────────────────────────────────────────────────
|
||||
|
||||
test('跑兩次結果一樣,而且不留下任何游標或狀態檔', async (t) => {
|
||||
const { repo, home, run } = await withScene(t, { general: [{ body: '一則意見' }] });
|
||||
|
||||
const first = await run();
|
||||
const second = await run();
|
||||
|
||||
assert.deepEqual(second.json, first.json, '不與上次比較,同樣的現況就該得到同樣的答案');
|
||||
assert.deepEqual(readdirSync(home), ['worktrees'], '家目錄底下只該有工作樹本身');
|
||||
assert.equal(repo.git('status', '--porcelain'), '', '目標專案裡不留任何東西');
|
||||
});
|
||||
|
||||
// ── --dry-run ─────────────────────────────────────────────────────
|
||||
|
||||
test('--dry-run 印出將執行的 git 指令與將發出的請求,且不清理', async (t) => {
|
||||
const { run, worktree } = await withScene(t, { state: 'closed', merged: true });
|
||||
|
||||
const { code, json } = await run(['--dry-run']);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.dryRun, true);
|
||||
assert.deepEqual(json.data.commands, [`git worktree remove ${worktree}`]);
|
||||
assert.ok(json.data.requests.some((r) => r.path.endsWith(`/pulls/${INDEX}`)));
|
||||
assert.equal(json.data.cleaned, false);
|
||||
assert.equal(json.data.suggestedAction, 'cleanup', '試跑不動手,該做的事要說出來');
|
||||
assert.equal(existsSync(worktree), true);
|
||||
});
|
||||
|
||||
test('--dry-run 在還不該清理的狀態下不印 git 指令', async (t) => {
|
||||
const { run } = await withScene(t, { state: 'open' });
|
||||
|
||||
const { json } = await run(['--dry-run']);
|
||||
|
||||
assert.deepEqual(json.data.commands, []);
|
||||
});
|
||||
|
||||
// ── 錯誤 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('PR 不存在時回可區分的錯誤碼', async (t) => {
|
||||
const { stub, run } = await withScene(t);
|
||||
stub.requests.length = 0;
|
||||
|
||||
const { json } = await runScript(
|
||||
'pr-watch.js',
|
||||
['--repo', REPO, '--index', '999'],
|
||||
{ env: stubEnv(stub) },
|
||||
);
|
||||
|
||||
assert.equal(json.error.code, 'PULL_NOT_FOUND');
|
||||
});
|
||||
|
||||
test('--index 不是正整數時擋在打 Gitea 之前', async (t) => {
|
||||
const { stub } = await withScene(t);
|
||||
const before = stub.requests.length;
|
||||
|
||||
const { json } = await runScript('pr-watch.js', ['--repo', REPO, '--index', '0'], {
|
||||
env: stubEnv(stub),
|
||||
});
|
||||
|
||||
assert.equal(json.error.code, 'BAD_INDEX');
|
||||
assert.equal(stub.requests.length, before);
|
||||
});
|
||||
+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');
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { runScript } from './helpers/run-script.js';
|
||||
import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js';
|
||||
import { healthyRoutes, stubEnv as envFor, withStubGitea, patchOf } from './helpers/stub-gitea.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
const INDEX = 12;
|
||||
@@ -39,7 +39,6 @@ const run = (args, stub) =>
|
||||
env: envFor(stub),
|
||||
});
|
||||
|
||||
const patchOf = (stub) => stub.requests.find((r) => r.method === 'PATCH' && !r.path.endsWith('/0'));
|
||||
|
||||
// ── 以名稱指定 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { repoRoot, runBin, tmpRoot } from './helpers/run-script.js';
|
||||
|
||||
@@ -22,6 +22,21 @@ function commands(text) {
|
||||
}
|
||||
|
||||
|
||||
test('說明不得宣稱正本沒到齊——prompts/ 裡有幾份,說明就得跟著', () => {
|
||||
// 「裝了也沒指令可用」這種過時的說法,會讓使用者以為工具還不能用而不去裝;
|
||||
// 同一句話在 AGENTS.md 裡還會誤導下一個 agent。
|
||||
const 正本數 = readdirSync(join(repoRoot, 'prompts')).filter((f) => f.endsWith('.md')).length;
|
||||
|
||||
for (const [名字, 內容] of [['README.md', readme()], ['AGENTS.md', agents()]]) {
|
||||
const 說沒到齊 = /正本尚未到齊|仍在實作中/.test(內容);
|
||||
assert.equal(
|
||||
說沒到齊,
|
||||
正本數 < 6,
|
||||
`${名字} 對正本進度的說法與 prompts/ 的實際份數(${正本數})對不上`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('安裝以 npm 為唯一建議路徑,指令含完整可複製的 git URL', () => {
|
||||
const install = commands(readme()).filter((line) => line.startsWith('npm i -g'));
|
||||
|
||||
@@ -131,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: [
|
||||
|
||||
@@ -55,6 +55,28 @@ test('--repo 格式不是 owner/name 時失敗', async (t) => {
|
||||
assert.equal(json.error.code, 'BAD_REPO');
|
||||
});
|
||||
|
||||
test('--key=value 的值可以本身就以 -- 開頭', async (t) => {
|
||||
// commit 訊息與 PR 描述裡出現 --flag 是常態;空格分隔的寫法分不出來,等號寫法可以
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await runScript('labels-list.js', ['--repo=--看起來像 flag 的值'], {
|
||||
env: envFor(stub),
|
||||
});
|
||||
|
||||
assert.equal(json.error.code, 'BAD_REPO', '要走到 repo 格式檢查,而不是被當成缺值');
|
||||
});
|
||||
|
||||
test('--key value 的值以 -- 開頭時仍然擋下,並指出等號寫法', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await runScript('labels-list.js', ['--repo', '--看起來像 flag 的值'], {
|
||||
env: envFor(stub),
|
||||
});
|
||||
|
||||
assert.equal(json.error.code, 'MISSING_FLAG');
|
||||
assert.match(json.error.message, /--repo=/);
|
||||
});
|
||||
|
||||
test('--key=value 與 --key value 兩種寫法等價', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
|
||||
@@ -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'), /還沒有工作包/);
|
||||
});
|
||||
|
||||
@@ -1,97 +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 分流 = prompt.slice(prompt.indexOf('## 交付文件待辦分流'), prompt.indexOf('## 實作與交付'));
|
||||
|
||||
// ── 正本基本契約 ───────────────────────────────────────────────────
|
||||
|
||||
test('正本平台中立,description 前綴正確', () => {
|
||||
assertNeutralPrompt(prompt, 'sdlc-feat');
|
||||
});
|
||||
|
||||
test('第一段指名三支腳本,順序為先讀再領再備分支', () => {
|
||||
const order = ['wp-extract.js', 'claim.js', 'branch-prep.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('未處理留言不是 0 時要先停下來提示整併', () => {
|
||||
assert.match(phase1, /未處理留言數/);
|
||||
assert.match(phase1, /先停下來/);
|
||||
assert.match(phase1, /sdlc-sync/);
|
||||
test('保留 worktree、依賴、驗收與 PR 的既有交付流程', () => {
|
||||
assert.match(prompt, /依工作包 `repos` 逐一準備 worktree/);
|
||||
assert.match(prompt, /立即勾選對應驗收/);
|
||||
assert.match(prompt, /scripts\/pr-create\.js --dry-run/);
|
||||
});
|
||||
|
||||
test('領取鎖的四種狀態各自交代了下一步,含放行那一種', () => {
|
||||
for (const code of ['CLAIMED_BY_OTHER', 'STOPWATCH_ON_THIS_ISSUE', 'STOPWATCH_ON_OTHER_ISSUE']) {
|
||||
assert.match(phase1, new RegExp(code), `${code} 要出現在決策表裡`);
|
||||
}
|
||||
assert.match(phase1, /沒有鎖/, '第四種狀態(放行)也要在表上,否則只剩擋的那幾種');
|
||||
assert.match(phase1, /不要繞過去/, '被擋下來的處置要明講,不能靠 agent 自由發揮');
|
||||
// ── 交付文件分流 ───────────────────────────────────────────────────
|
||||
|
||||
test('交付文件待辦與程式碼待辦走不同流程', () => {
|
||||
assert.match(分流, /判定為程式碼待辦或交付文件待辦/);
|
||||
assert.match(分流, /不可共用無差別的實作流程/);
|
||||
assert.match(分流, /references\/delivery-types\.md/);
|
||||
});
|
||||
|
||||
test('缺標籤是前置條件,不混進領取鎖的四種狀態裡', () => {
|
||||
const table = phase1.slice(phase1.indexOf('| 狀態'), phase1.indexOf('碼錶一律由使用者自己停'));
|
||||
assert.equal(table.includes('LABEL_NOT_FOUND'), false, '它不是鎖的狀態,別讓四種變五種');
|
||||
assert.match(phase1, /LABEL_NOT_FOUND/, '但仍要交代它,否則使用者不知道怎麼辦');
|
||||
test('每份文件產出前逐一列骨架並一次確認一題', () => {
|
||||
assert.match(分流, /每一份交付文件都要分開處理/);
|
||||
assert.match(分流, /必要內容骨架、已知來源、未決事項、建議與理由/);
|
||||
assert.match(分流, /一次只問一題確認內容是否齊全/);
|
||||
assert.match(分流, /未確認的文件不可標成已交付/);
|
||||
});
|
||||
|
||||
test('工作包跨多個 repo 時怎麼開分支,有交代', () => {
|
||||
assert.match(phase1, /repos/);
|
||||
assert.match(phase1, /有多顆時逐一確認/);
|
||||
test('預覽能力以可開啟可分享能力判斷,具備時直接產出', () => {
|
||||
assert.match(分流, /即時發佈成可開啟、可分享的預覽頁/);
|
||||
assert.match(分流, /具備能力後直接產出預覽/);
|
||||
assert.match(分流, /回報實際位置與內容摘要/);
|
||||
});
|
||||
|
||||
test('工作區不乾淨時的處置寫明了,且不替使用者決定', () => {
|
||||
assert.match(phase1, /DIRTY_WORKTREE/);
|
||||
assert.match(phase1, /不要自己選/);
|
||||
test('預覽能力不足時一次一題詢問,建議由情境推導', () => {
|
||||
assert.match(分流, /無法證明具備能力、預覽不可開啟或不可分享/);
|
||||
assert.match(分流, /一次只問一題/);
|
||||
assert.match(分流, /依文件、來源與限制提出建議及理由/);
|
||||
assert.match(分流, /保留手動輸入/);
|
||||
assert.match(分流, /不替使用者決定交付方式/);
|
||||
assert.match(分流, /不要靠固定清單或猜測環境/);
|
||||
assert.match(prompt, /不產生固定選項清單/);
|
||||
});
|
||||
|
||||
test('碼錶只由使用者自己停,並說明為什麼不代勞', () => {
|
||||
assert.match(phase1, /由使用者自己停/);
|
||||
assert.match(phase1, /工時記錯地方/);
|
||||
// ── ELI5 與契約邊界 ─────────────────────────────────────────────────
|
||||
|
||||
test('ELI5 保留技術意義並把圖表改成圖片式視覺', () => {
|
||||
assert.match(分流, /保留原文件的範圍、順序、相依、例外與驗收意義/);
|
||||
assert.match(分流, /不刪除技術限制/);
|
||||
assert.match(分流, /重新繪製成容易閱讀的圖片式視覺/);
|
||||
assert.match(分流, /不把 Mermaid 原碼當成交付物/);
|
||||
});
|
||||
|
||||
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, /pull 而不是重建/);
|
||||
assert.match(phase1, /接上去而不是蓋掉/);
|
||||
});
|
||||
|
||||
test('三支腳本的寫入都要求先試跑', () => {
|
||||
const dryRuns = phase1.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, /不開 PR/);
|
||||
assert.match(boundary, /不自行建立標籤/);
|
||||
assert.match(boundary, /不寫任何本機狀態檔/);
|
||||
assert.match(boundary, /換一台機器或換一個 agent/, '要說明為什麼不留狀態檔');
|
||||
test('API 契約只在預覽或確認位置交付,不寫入目標 repo', () => {
|
||||
assert.match(分流, /API 契約文件只能交付在預覽位置或使用者確認的位置/);
|
||||
assert.match(分流, /不得寫入目標專案 repo/);
|
||||
assert.match(分流, /不捏造網址/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* /sdlc-fix 的流程正本。
|
||||
*
|
||||
* 這一段有四件事只有正本做得到,腳本擋不住:把三種輸入判到對的路上、分類必改/建議、
|
||||
* 不確定時停下來問、以及最後那則摘要。寫漏任何一件,reviewer 的意見就會被靜靜跳過——
|
||||
* 而那正是這個指令存在的理由。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { assertNeutralPrompt, readPrompt } from './helpers/prompt-doc.js';
|
||||
|
||||
const prompt = readPrompt('sdlc-fix');
|
||||
const steps = prompt.slice(prompt.indexOf('## 1.'), prompt.indexOf('## 邊界'));
|
||||
|
||||
/** 框出一個步驟的範圍,讓各步驟分開看 */
|
||||
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, /不要繼續處理留言/);
|
||||
assert.match(locate, /白做工/, '要說明為什麼停:那棵工作樹已經該被清掉了');
|
||||
});
|
||||
|
||||
test('查現況帶 --dry-run,看一下留言不該順手清掉工作樹', () => {
|
||||
const 指令 = locate.slice(locate.indexOf('pr-watch.js'));
|
||||
assert.match(指令.slice(0, 120), /--dry-run/);
|
||||
assert.match(locate, /清不清理是使用者的決定/);
|
||||
});
|
||||
|
||||
test('三個建議動作的意思都交代了,不留一個讓 agent 自由發揮', () => {
|
||||
for (const 值 of ['nothing-to-do', 'cleanup', 'blocked-dirty']) {
|
||||
assert.match(locate, new RegExp(值));
|
||||
}
|
||||
});
|
||||
|
||||
test('工作樹由工作包推導,不要求使用者自己切目錄', () => {
|
||||
assert.match(locate, /worktree-ensure\.js/);
|
||||
assert.match(locate, /--path/, '目標專案不是當前目錄時要指得出來,否則會找錯 repo');
|
||||
assert.match(locate, /branch/, '輸入是 pr-watch 給的分支名');
|
||||
assert.match(locate, /不必也不該由使用者自己去記/);
|
||||
assert.match(locate, /都在那棵工作樹裡做/);
|
||||
});
|
||||
|
||||
test('不存在就重建,並說明那是常態不是例外', () => {
|
||||
assert.match(locate, /常態不是例外/);
|
||||
assert.match(locate, /換一台機器/, '要說明為什麼工作樹常常不在');
|
||||
assert.match(locate, /同一套建立方式/, '重建不另寫一套');
|
||||
});
|
||||
|
||||
test('兩種擋下來的情況各自寫明處置,且不替使用者決定', () => {
|
||||
assert.match(locate, /BRANCH_NOT_FOUND/);
|
||||
assert.match(locate, /WORKTREE_PATH_TAKEN/);
|
||||
assert.match(locate, /不要自己刪/);
|
||||
});
|
||||
|
||||
test('邊界擋住「回主工作區處理留言」與「PR 結束了還繼續改」', () => {
|
||||
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
|
||||
assert.match(boundary, /不在主工作區處理留言/);
|
||||
assert.match(boundary, /已經合併或關閉時不繼續處理留言/);
|
||||
});
|
||||
|
||||
test('正本平台中立,description 前綴正確', () => {
|
||||
assertNeutralPrompt(prompt, 'sdlc-fix');
|
||||
});
|
||||
|
||||
test('指名兩支腳本,順序為先讀再回', () => {
|
||||
const order = ['pr-comments.js', 'pr-reply.js'];
|
||||
const positions = order.map((name) => steps.indexOf(name));
|
||||
assert.equal(positions.every((p) => p >= 0), true, '兩支腳本都要被指名');
|
||||
assert.deepEqual([...positions].sort((a, b) => a - b), positions);
|
||||
});
|
||||
|
||||
test('三類留言都點名,且交代行內的 diff 要看', () => {
|
||||
for (const kind of ['一般留言', 'review 總評', '行內留言']) {
|
||||
assert.match(steps, new RegExp(kind), `缺少:${kind}`);
|
||||
}
|
||||
assert.match(steps, /不要略過不看/);
|
||||
});
|
||||
|
||||
test('三類的標記機制都交代了,並說明 +1 要是自己打的', () => {
|
||||
assert.match(steps, /三類都標記得了/);
|
||||
assert.match(steps, /resolve/);
|
||||
assert.match(steps, /自己打的/);
|
||||
assert.match(steps, /我同意/, '要說出為什麼別人的 \+1 不算');
|
||||
});
|
||||
|
||||
test('標不了的那幾則要在摘要裡單獨點出來', () => {
|
||||
assert.match(steps, /可標記/);
|
||||
assert.match(steps, /單獨點出來|單獨\n? 列出來|單獨列出來/);
|
||||
});
|
||||
|
||||
test('留言指向的程式碼已被改掉時的處置有交代', () => {
|
||||
assert.match(steps, /位置對不上/);
|
||||
assert.match(steps, /不要硬試/);
|
||||
});
|
||||
|
||||
// ── 分類 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('必改與建議各有判斷依據,不是只給兩個詞', () => {
|
||||
assert.match(steps, /\*\*必改\*\* — .{10,}/);
|
||||
assert.match(steps, /\*\*建議\*\* — .{10,}/);
|
||||
});
|
||||
|
||||
test('分類結果要先呈現給使用者再動手', () => {
|
||||
assert.match(steps, /先呈現給使用者/);
|
||||
});
|
||||
|
||||
test('拿不準時往必改那邊靠,並說明為什麼', () => {
|
||||
assert.match(steps, /歸到必改/);
|
||||
assert.match(steps, /代價不對稱/);
|
||||
});
|
||||
|
||||
// ── 不確定就問 ─────────────────────────────────────────────────────
|
||||
|
||||
test('一次問一題,選項含手動輸入', () => {
|
||||
assert.match(steps, /一次問一題/);
|
||||
assert.match(steps, /手動輸入/);
|
||||
});
|
||||
|
||||
test('該問的情況有列舉,不是一句「不確定就問」', () => {
|
||||
const section = step('## 6.', '## 7.');
|
||||
const bullets = section.match(/^- /gm) ?? [];
|
||||
assert.ok(bullets.length >= 3, `該問的情況要列得出來,只找到 ${bullets.length} 條`);
|
||||
assert.match(section, /推了新 commit/, '位置對不上是最常見的一種,要點名');
|
||||
});
|
||||
|
||||
test('說明了硬改的代價', () => {
|
||||
assert.match(steps, /比多問一題貴得多/);
|
||||
});
|
||||
|
||||
// ── 逐則處理 ───────────────────────────────────────────────────────
|
||||
|
||||
test('要一則一則回,不是全部改完才一起回', () => {
|
||||
assert.match(steps, /不要全部改完才一起回/);
|
||||
assert.match(steps, /中途斷掉/);
|
||||
});
|
||||
|
||||
test('三類與 --kind 的對應寫出來了,並警告 id 各自獨立', () => {
|
||||
assert.match(steps, /行內.*inline/);
|
||||
assert.match(steps, /一般.*general/);
|
||||
assert.match(steps, /總評.*review/);
|
||||
assert.match(steps, /id 各自獨立/);
|
||||
});
|
||||
|
||||
test('回覆要說出做了什麼,且決定不改的也要回', () => {
|
||||
assert.match(steps, /不是「已修正」/);
|
||||
assert.match(steps, /決定不改的也要回/);
|
||||
assert.match(steps, /沉默會讓 reviewer 以為被忽略/);
|
||||
});
|
||||
|
||||
test('回覆失敗就不標記的理由有寫', () => {
|
||||
assert.match(steps, /謊稱處理過/);
|
||||
});
|
||||
|
||||
test('寫入前要求先試跑', () => {
|
||||
assert.match(steps, /--dry-run/);
|
||||
});
|
||||
|
||||
// ── 摘要 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('摘要要逐則列出,並單獨點出沒有記號的那幾則', () => {
|
||||
const section = steps.slice(steps.indexOf('## 8.')); // 最後一步,到結尾為止
|
||||
assert.match(section, /逐則一行/);
|
||||
assert.match(section, /沒有留下記號的那幾則/);
|
||||
assert.match(section, /以為它們被跳過/, '要說出為什麼得單獨列');
|
||||
});
|
||||
|
||||
test('摘要不自動張貼', () => {
|
||||
assert.match(steps, /不自動張貼/);
|
||||
});
|
||||
|
||||
// ── 邊界 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('邊界列出不做的事,含不動 review 狀態', () => {
|
||||
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
|
||||
assert.match(boundary, /不改與留言無關的程式碼/);
|
||||
assert.match(boundary, /不跳過任何一則留言/);
|
||||
assert.match(boundary, /不自動張貼修正摘要/);
|
||||
assert.match(boundary, /不動 PR 的 review 狀態/);
|
||||
});
|
||||
@@ -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, /週報沒有分段/);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* /sdlc-sync 的流程正本,以及另外兩份正本的「接回」那一段。
|
||||
*
|
||||
* 這個指令的價值在於「描述不再騙人」,而三件關鍵事只有正本做得到:挑出哪些留言真的是
|
||||
* 決策、寫回去之前讓使用者點頭、以及略過的那幾則要保持未標記。寫漏任何一件,
|
||||
* 描述就會繼續過期,或者有決策被靜靜吞掉。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { assertNeutralPrompt, readPrompt } from './helpers/prompt-doc.js';
|
||||
|
||||
const prompt = readPrompt('sdlc-sync');
|
||||
const steps = prompt.slice(prompt.indexOf('## 1.'), prompt.indexOf('## 邊界'));
|
||||
|
||||
test('正本平台中立,description 前綴正確', () => {
|
||||
assertNeutralPrompt(prompt, 'sdlc-sync');
|
||||
});
|
||||
|
||||
test('兩種議題各自指名對應的抽取腳本', () => {
|
||||
assert.match(steps, /issue-extract/);
|
||||
assert.match(steps, /wp-extract/);
|
||||
assert.match(steps, /需求議題.*工作包議題|工作包議題.*需求議題/s);
|
||||
});
|
||||
|
||||
test('留言內容另外拿,並說明為什麼抽取契約不夠', () => {
|
||||
assert.match(steps, /pr-comments/);
|
||||
assert.match(steps, /只給數字不給內容/);
|
||||
});
|
||||
|
||||
test('說明那支腳本議題與 PR 都收得下,不再宣稱「PR 也是 issue」', () => {
|
||||
assert.match(steps, /議題與 PR 都收得下/);
|
||||
assert.equal(steps.includes('Gitea 的 PR 也是 issue'), false, '反過來說才對:每個 PR 都是議題');
|
||||
});
|
||||
|
||||
test('已處理認的是自己打的 +1', () => {
|
||||
assert.match(steps, /自己打的/);
|
||||
assert.match(steps, /我同意/);
|
||||
});
|
||||
|
||||
test('略過會讓計數停在大於 0,這件事要講明白', () => {
|
||||
const section = steps.slice(steps.indexOf('## 5.'));
|
||||
assert.match(section, /停在大於 0/);
|
||||
assert.match(section, /每次開始時都會再停一次/);
|
||||
assert.match(section, /他選了略過/, '要讓使用者分得出「沒整併乾淨」與「選了略過」');
|
||||
});
|
||||
|
||||
test('已標記過的留言跳過', () => {
|
||||
assert.match(steps, /已經標記過的留言不再處理|已處理.*跳過/s);
|
||||
});
|
||||
|
||||
// ── 挑決策 ─────────────────────────────────────────────────────────
|
||||
|
||||
test('要整併與不整併各有判斷依據,不是只給兩個詞', () => {
|
||||
assert.match(steps, /\*\*要整併\*\* — .{10,}/);
|
||||
assert.match(steps, /\*\*不整併\*\* — .{10,}/);
|
||||
});
|
||||
|
||||
test('判斷不了時當成要整併,並說出漏掉的代價', () => {
|
||||
assert.match(steps, /判斷不了的\*\*當成要整併\*\*/);
|
||||
assert.match(steps, /描述就會繼續騙後面的人/);
|
||||
});
|
||||
|
||||
// ── 先點頭再寫 ─────────────────────────────────────────────────────
|
||||
|
||||
test('寫回去之前要列出方案給使用者看', () => {
|
||||
assert.match(steps, /先列出來再動手/);
|
||||
assert.match(steps, /哪一段/);
|
||||
assert.match(steps, /改完長什麼樣/);
|
||||
});
|
||||
|
||||
test('一次問一題,且略過的會保持未標記', () => {
|
||||
assert.match(steps, /一次問一題/);
|
||||
assert.match(steps, /略過的留言保持未標記/);
|
||||
assert.match(steps, /下次執行還會被提出來/);
|
||||
});
|
||||
|
||||
test('說明了這一步是描述的最後一道關卡', () => {
|
||||
assert.match(steps, /最後一道關卡/);
|
||||
assert.match(steps, /拿它當事實/);
|
||||
});
|
||||
|
||||
// ── 寫回 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('指名 comments-merge,並要求先試跑', () => {
|
||||
assert.match(steps, /comments-merge\.js/);
|
||||
assert.match(steps, /--dry-run/);
|
||||
});
|
||||
|
||||
test('--content-file 的內容是整段,且說明標題那一行不含在內', () => {
|
||||
assert.match(steps, /那一段改完的完整內容/);
|
||||
assert.match(steps, /不含 `## 標題` 那一行/);
|
||||
});
|
||||
|
||||
test('--merged 只放真的併進去的,並說出放錯的後果', () => {
|
||||
assert.match(steps, /只放\*\*真的被併進這一段\*\*/);
|
||||
assert.match(steps, /再也\n?不會被提出來|再也不會被提出來/);
|
||||
});
|
||||
|
||||
test('SECTION_NOT_FOUND 的處置是回頭確認,不是硬塞別的段落', () => {
|
||||
assert.match(steps, /SECTION_NOT_FOUND/);
|
||||
assert.match(steps, /不要改用別的段落硬塞/);
|
||||
});
|
||||
|
||||
// ── 回報 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('回報要列出略過的那幾則', () => {
|
||||
const section = steps.slice(steps.indexOf('## 5.'));
|
||||
assert.match(section, /略過的那幾則是哪些/);
|
||||
assert.match(section, /下次還會出現/);
|
||||
});
|
||||
|
||||
// ── 接回原本的指令 ─────────────────────────────────────────────────
|
||||
|
||||
test('整併完自動接回,不要求使用者重打指令', () => {
|
||||
const section = prompt.slice(prompt.indexOf('## 接回原本的指令'));
|
||||
assert.match(section, /直接接回去/);
|
||||
assert.match(section, /不要要求使用者重打一次/);
|
||||
});
|
||||
|
||||
test('接回之前要重新抽取,並說明為什麼', () => {
|
||||
const section = prompt.slice(prompt.indexOf('## 接回原本的指令'));
|
||||
assert.match(section, /先重跑一次抽取/);
|
||||
assert.match(section, /這一整段就白做了/);
|
||||
});
|
||||
|
||||
// ── 邊界 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('邊界列出不做的事,含不刪改留言', () => {
|
||||
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
|
||||
assert.match(boundary, /不自行決定要不要整併/);
|
||||
assert.match(boundary, /不整份重寫描述/);
|
||||
assert.match(boundary, /不標記略過的留言/);
|
||||
assert.match(boundary, /不刪除、不編輯任何留言/);
|
||||
assert.match(boundary, /誰說過什麼的紀錄/, '要說出為什麼留言不能動');
|
||||
});
|
||||
|
||||
// ── 另外兩份正本的接回 ─────────────────────────────────────────────
|
||||
|
||||
for (const name of ['sdlc-analyze', 'sdlc-feat']) {
|
||||
test(`${name} 偵測到未整併留言時會轉去整併,並自動接回`, () => {
|
||||
const other = readPrompt(name);
|
||||
assert.match(other, /未處理留言數/);
|
||||
assert.match(other, /直接走 `\/sdlc-sync` 的流程/, `${name} 要真的轉過去,不是叫人自己跑`);
|
||||
assert.match(other, /自動接回這裡/);
|
||||
assert.match(other, /不要要求使用者重打指令/);
|
||||
});
|
||||
|
||||
test(`${name} 接回前要重新抽取一次`, () => {
|
||||
assert.match(readPrompt(name), /重新抽取一次拿到更新後的描述/);
|
||||
});
|
||||
}
|
||||
@@ -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, /只在超過上限拆不開、或畫了不會比文字更清楚時/);
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* 定位一顆工作包的工作樹,不在就重建。
|
||||
*
|
||||
* 「不在就重建」不是防禦性程式設計,而是最常見的情境:進度完全不寫在本機,換一台機器
|
||||
* 或換一個 agent 接手時,工作樹本來就不存在。重建的成本就是一次 `git worktree add`。
|
||||
*
|
||||
* 重建走的是與 branch-prep 同一套推導與同一套建立方式(`lib` 的 `worktreePath` 與
|
||||
* `planWorktree`),所以這裡連帶驗兩件事:算出來的是同一條路徑,而且分支上已經有的
|
||||
* 進度會被接上,不是從頭長一棵空的。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { runScript, tmpRoot } from './helpers/run-script.js';
|
||||
import { makeTempRepoWithRemote } from './helpers/temp-repo.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
const SLUG = 'mine';
|
||||
const BRANCH = `feat/${SLUG}/main`;
|
||||
|
||||
/** 一個有遠端的臨時 repo 與一個空的工作樹家 */
|
||||
function withRepo(t) {
|
||||
const repo = makeTempRepoWithRemote();
|
||||
t.after(() => repo.cleanup());
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
const home = mkdtempSync(join(tmpRoot, 'home-'));
|
||||
t.after(() => rmSync(home, { recursive: true, force: true }));
|
||||
return { ...repo, home };
|
||||
}
|
||||
|
||||
/** 照正規流程開一棵工作樹,回傳它的路徑——測試不自己算路徑 */
|
||||
async function prep(repo) {
|
||||
const { json } = await runScript(
|
||||
'branch-prep.js',
|
||||
['--repo', REPO, '--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', SLUG],
|
||||
{ env: { TEA_SDLC_HOME: repo.home } },
|
||||
);
|
||||
assert.equal(json.ok, true, json.error?.message);
|
||||
return json.data.worktree;
|
||||
}
|
||||
|
||||
const run = (repo, args = []) =>
|
||||
runScript('worktree-ensure.js', ['--repo', REPO, '--path', repo.dir, '--branch', BRANCH, ...args], {
|
||||
env: { TEA_SDLC_HOME: repo.home },
|
||||
});
|
||||
|
||||
const branchIn = (dir) =>
|
||||
execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim();
|
||||
|
||||
// ── 已經在了就沿用 ─────────────────────────────────────────────────
|
||||
|
||||
test('工作樹已經在了就沿用,不碰裡面還沒提交的東西', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
const worktree = await prep(repo);
|
||||
writeFileSync(join(worktree, 'wip.txt'), '做到一半\n');
|
||||
|
||||
const { code, json } = await run(repo);
|
||||
|
||||
assert.equal(code, 0, json.error?.message);
|
||||
assert.equal(json.data.worktree, worktree, '推導出的要是 branch-prep 建的那一條');
|
||||
assert.equal(json.data.動作, '沿用既有工作樹');
|
||||
assert.equal(existsSync(join(worktree, 'wip.txt')), true);
|
||||
});
|
||||
|
||||
// ── 不在就重建 ─────────────────────────────────────────────────────
|
||||
|
||||
test('工作樹不在時重建它,分支上的進度跟著回來', async (t) => {
|
||||
// 換一台機器接手就是這個情形:進度不寫在本機,工作樹本來就不存在
|
||||
const repo = withRepo(t);
|
||||
const worktree = await prep(repo);
|
||||
writeFileSync(join(worktree, 'done.txt'), '已經提交的進度\n');
|
||||
execFileSync('git', ['add', '-A'], { cwd: worktree });
|
||||
execFileSync('git', ['commit', '-qm', '分支上的進度'], { cwd: worktree });
|
||||
repo.git('worktree', 'remove', worktree);
|
||||
|
||||
const { code, json } = await run(repo);
|
||||
|
||||
assert.equal(code, 0, json.error?.message);
|
||||
assert.equal(json.data.worktree, worktree, '同一顆工作包算出來的永遠是同一條路徑');
|
||||
assert.equal(json.data.動作, '接上本地既有');
|
||||
assert.equal(branchIn(worktree), BRANCH);
|
||||
assert.equal(existsSync(join(worktree, 'done.txt')), true, '接上既有分支,不是長一棵空的');
|
||||
});
|
||||
|
||||
test('本機連分支都沒有時,從遠端那一支重建', async (t) => {
|
||||
// 真正的新機器:clone 完什麼都沒有,分支只在遠端上
|
||||
const repo = withRepo(t);
|
||||
repo.pushFromElsewhere(BRANCH, 'theirs.txt', '推上去的進度\n');
|
||||
|
||||
const { code, json } = await run(repo);
|
||||
|
||||
assert.equal(code, 0, json.error?.message);
|
||||
assert.equal(json.data.動作, '接上遠端既有');
|
||||
assert.equal(existsSync(join(json.data.worktree, 'theirs.txt')), true);
|
||||
assert.equal(
|
||||
repo.git('for-each-ref', '--format=%(upstream)', `refs/heads/${BRANCH}`),
|
||||
'',
|
||||
'重建與建立走同一套:一樣不設 upstream',
|
||||
);
|
||||
});
|
||||
|
||||
test('中繼資料還在但目錄被砍掉時照樣重建', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
const worktree = await prep(repo);
|
||||
rmSync(worktree, { recursive: true, force: true });
|
||||
|
||||
const { code, json } = await run(repo);
|
||||
|
||||
assert.equal(code, 0, json.error?.message);
|
||||
assert.equal(existsSync(worktree), true);
|
||||
assert.equal(branchIn(worktree), BRANCH);
|
||||
});
|
||||
|
||||
// ── 擋下來的情況 ───────────────────────────────────────────────────
|
||||
|
||||
test('分支在本機與遠端都不存在時明確報錯,不憑空長一棵', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
|
||||
const { code, json } = await run(repo);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'BRANCH_NOT_FOUND');
|
||||
assert.match(json.error.message, new RegExp(BRANCH));
|
||||
assert.equal(existsSync(json.data?.worktree ?? '/nonexistent'), false);
|
||||
});
|
||||
|
||||
test('推導出的路徑上是別的東西時明確報錯,不盲目拿來用', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
const worktree = await prep(repo);
|
||||
repo.git('worktree', 'remove', worktree);
|
||||
mkdirSync(worktree, { recursive: true });
|
||||
writeFileSync(join(worktree, '別人的東西.txt'), 'x\n');
|
||||
|
||||
const { code, json } = await run(repo);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'WORKTREE_PATH_TAKEN');
|
||||
assert.equal(existsSync(join(worktree, '別人的東西.txt')), true, '不是我們建的東西就不碰');
|
||||
});
|
||||
|
||||
test('--path 指向的不是 git repo 時,回可區分的錯誤碼', async (t) => {
|
||||
const { json } = await runScript('worktree-ensure.js', [
|
||||
'--repo', REPO, '--path', tmpRoot, '--branch', BRANCH,
|
||||
]);
|
||||
|
||||
assert.equal(json.error.code, 'NOT_A_GIT_REPO');
|
||||
});
|
||||
|
||||
test('缺 --branch 時指名缺的是哪一個', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
|
||||
const { json } = await runScript('worktree-ensure.js', ['--repo', REPO, '--path', repo.dir], {
|
||||
env: { TEA_SDLC_HOME: repo.home },
|
||||
});
|
||||
|
||||
assert.equal(json.error.code, 'MISSING_FLAG');
|
||||
assert.match(json.error.message, /--branch/);
|
||||
});
|
||||
|
||||
// ── --dry-run ─────────────────────────────────────────────────────
|
||||
|
||||
test('--dry-run 印出路徑與將執行的 git 指令,且不建任何東西', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
repo.pushFromElsewhere(BRANCH, 'theirs.txt', '推上去的進度\n');
|
||||
|
||||
const { code, json } = await run(repo, ['--dry-run']);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.dryRun, true);
|
||||
assert.match(json.data.worktree, /worktrees\/[0-9a-f]{12}$/);
|
||||
assert.deepEqual(json.data.commands, [
|
||||
'git fetch origin',
|
||||
`git worktree add --no-track -b ${BRANCH} ${json.data.worktree} origin/${BRANCH}`,
|
||||
]);
|
||||
assert.equal(existsSync(json.data.worktree), false, '試跑不該真的建');
|
||||
});
|
||||
|
||||
test('--dry-run 在工作樹已經在時說沒事要做', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
await prep(repo);
|
||||
|
||||
const { json } = await run(repo, ['--dry-run']);
|
||||
|
||||
assert.equal(json.data.動作, '沿用既有工作樹');
|
||||
assert.deepEqual(json.data.commands, []);
|
||||
});
|
||||
|
||||
test('--dry-run 也照樣把擋下來的情況說出來', async (t) => {
|
||||
const repo = withRepo(t);
|
||||
|
||||
const { json } = await run(repo, ['--dry-run']);
|
||||
|
||||
assert.equal(json.error.code, 'BRANCH_NOT_FOUND');
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 工作樹路徑的推導。
|
||||
*
|
||||
* 這是一個純函式,而且是整套工作樹機制的地基:任何流程都要能從「這是哪顆工作包」
|
||||
* 算出「它的工作樹在哪」,不查表、不讀狀態檔,換一台機器算出來也要一樣。
|
||||
* 算錯的代價是找不到既有的工作樹而重建一棵,或兩顆工作包撞進同一個目錄。
|
||||
*
|
||||
* 這一支直接 import lib,是本專案第二個這麼做的測試:大小寫與空白的變體沒辦法
|
||||
* 穿過 CLI 的 kebab 驗證送進去,而表格驅動正是這種純規則最划算的驗法。
|
||||
* 最後一則測試把它與 CLI 實際印出的路徑釘在一起,避免兩邊各自為政。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { worktreePath } from '../scripts/lib.js';
|
||||
import { runScript, tmpRoot } from './helpers/run-script.js';
|
||||
import { makeTempRepoWithRemote } from './helpers/temp-repo.js';
|
||||
|
||||
/** 推導只看輸入,不看環境;家目錄則由 TEA_SDLC_HOME 決定,測試固定一個值好比對 */
|
||||
const HOME = '/home/tester/.tea-sdlc';
|
||||
const derive = (repo, branch) => {
|
||||
process.env.TEA_SDLC_HOME = HOME;
|
||||
try {
|
||||
return worktreePath(repo, branch);
|
||||
} finally {
|
||||
delete process.env.TEA_SDLC_HOME;
|
||||
}
|
||||
};
|
||||
|
||||
test('路徑長在固定的家底下,目錄名是 12 碼十六進位', () => {
|
||||
const path = derive('plugins/tea-sdlc', 'feat/worktree-branch-prep/main');
|
||||
|
||||
assert.match(path, new RegExp(`^${HOME}/worktrees/[0-9a-f]{12}$`));
|
||||
});
|
||||
|
||||
// ── 同一顆工作包只能推導出一條路徑 ─────────────────────────────────
|
||||
|
||||
const SAME = [
|
||||
{
|
||||
name: '大小寫不同',
|
||||
a: ['plugins/tea-sdlc', 'feat/mine/main'],
|
||||
b: ['Plugins/Tea-SDLC', 'Feat/Mine/Main'],
|
||||
},
|
||||
{
|
||||
name: '前後有空白',
|
||||
a: ['plugins/tea-sdlc', 'feat/mine/main'],
|
||||
b: [' plugins/tea-sdlc ', ' feat/mine/main '],
|
||||
},
|
||||
{
|
||||
name: '斜線兩側有空白',
|
||||
a: ['plugins/tea-sdlc', 'feat/mine/main'],
|
||||
b: ['plugins / tea-sdlc', 'feat / mine / main'],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { name, a, b } of SAME) {
|
||||
test(`同一顆工作包:${name}的變體推導出同一條路徑`, () => {
|
||||
assert.equal(derive(...a), derive(...b), '正規化沒做,同一棵工作樹會被推導成兩個目錄');
|
||||
});
|
||||
}
|
||||
|
||||
// ── 不同的工作包不能撞在一起 ───────────────────────────────────────
|
||||
|
||||
const DIFFERENT = [
|
||||
{
|
||||
name: '分支名的斜線位置不同:feat/a-b/main 與 feat/a/b/main',
|
||||
a: ['plugins/tea-sdlc', 'feat/a-b/main'],
|
||||
b: ['plugins/tea-sdlc', 'feat/a/b/main'],
|
||||
},
|
||||
{
|
||||
name: '同名分支在不同的 repo 上',
|
||||
a: ['plugins/tea-sdlc', 'feat/mine/main'],
|
||||
b: ['plugins/別的專案', 'feat/mine/main'],
|
||||
},
|
||||
{
|
||||
name: '同名 repo 在不同的 owner 底下',
|
||||
a: ['plugins/tea-sdlc', 'feat/mine/main'],
|
||||
b: ['someone/tea-sdlc', 'feat/mine/main'],
|
||||
},
|
||||
{
|
||||
name: '同一個 repo 的不同分支',
|
||||
a: ['plugins/tea-sdlc', 'feat/mine/main'],
|
||||
b: ['plugins/tea-sdlc', 'feat/yours/main'],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { name, a, b } of DIFFERENT) {
|
||||
test(`不同的工作包:${name},不得撞名`, () => {
|
||||
assert.notEqual(derive(...a), derive(...b));
|
||||
});
|
||||
}
|
||||
|
||||
test('分支名含斜線時路徑仍是單層目錄,不會長出巢狀結構', () => {
|
||||
const path = derive('plugins/tea-sdlc', 'feat/a/b/c/main');
|
||||
|
||||
assert.equal(path.startsWith(`${HOME}/worktrees/`), true);
|
||||
assert.equal(path.slice(`${HOME}/worktrees/`.length).includes('/'), false);
|
||||
});
|
||||
|
||||
test('換一台機器只有家目錄會變,目錄名不變', () => {
|
||||
process.env.TEA_SDLC_HOME = '/somewhere/else/.tea-sdlc';
|
||||
const elsewhere = worktreePath('plugins/tea-sdlc', 'feat/mine/main');
|
||||
delete process.env.TEA_SDLC_HOME;
|
||||
|
||||
const here = derive('plugins/tea-sdlc', 'feat/mine/main');
|
||||
|
||||
assert.equal(elsewhere.split('/').at(-1), here.split('/').at(-1));
|
||||
});
|
||||
|
||||
// ── 與 CLI 釘在一起 ───────────────────────────────────────────────
|
||||
|
||||
test('branch-prep 印出的工作樹路徑就是這個函式算出來的那一條', async (t) => {
|
||||
const repo = makeTempRepoWithRemote();
|
||||
t.after(() => repo.cleanup());
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
const home = mkdtempSync(join(tmpRoot, 'home-'));
|
||||
t.after(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
const { json } = await runScript(
|
||||
'branch-prep.js',
|
||||
['--repo', 'plugins/tea-sdlc', '--path', repo.dir, '--source', 'master',
|
||||
'--type', 'feat', '--slug', 'mine', '--dry-run'],
|
||||
{ env: { TEA_SDLC_HOME: home } },
|
||||
);
|
||||
|
||||
process.env.TEA_SDLC_HOME = home;
|
||||
const expected = worktreePath('plugins/tea-sdlc', 'feat/mine/main');
|
||||
delete process.env.TEA_SDLC_HOME;
|
||||
assert.equal(json.data.worktree, expected);
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 手動清理工作樹。
|
||||
*
|
||||
* 這一支的重點全在**守門**:清理會被 pr-watch 自動執行,而自動執行的東西只能做
|
||||
* 可逆的事。工作樹裡還有沒提交的東西就中止,絕不 `--force`——刪掉的檔案救不回來。
|
||||
* 移除只動工作樹,本機分支留著,使用者還能回頭看那段歷史。
|
||||
*
|
||||
* 路徑由 `owner/repo/分支名` 推導,不查表也不讀狀態檔,所以這裡連帶驗它與
|
||||
* branch-prep 算出來的是同一條。
|
||||
*
|
||||
* git 不做替身:在臨時 git repo 上跑真的 git,以本機裸 repo 充當遠端,不需網路。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { runScript, tmpRoot } from './helpers/run-script.js';
|
||||
import { makeTempRepoWithRemote } from './helpers/temp-repo.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
|
||||
/**
|
||||
* 備好一棵真的工作樹:路徑由 branch-prep 建,測試不自己算,
|
||||
* 兩支腳本推導出同一條路徑這件事本身就是要驗的東西。
|
||||
*/
|
||||
async function withWorktree(t, slug = 'mine') {
|
||||
const repo = makeTempRepoWithRemote();
|
||||
t.after(() => repo.cleanup());
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
const home = mkdtempSync(join(tmpRoot, 'home-'));
|
||||
t.after(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
const { json } = await runScript(
|
||||
'branch-prep.js',
|
||||
['--repo', REPO, '--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', slug],
|
||||
{ env: { TEA_SDLC_HOME: home } },
|
||||
);
|
||||
assert.equal(json.ok, true, json.error?.message);
|
||||
return { repo, home, branch: json.data.branch, worktree: json.data.worktree };
|
||||
}
|
||||
|
||||
const run = (home, args) => runScript('worktree-remove.js', args, { env: { TEA_SDLC_HOME: home } });
|
||||
|
||||
// ── 乾淨時移除 ─────────────────────────────────────────────────────
|
||||
|
||||
test('工作樹乾淨時移除它,本機分支留著', async (t) => {
|
||||
const { repo, home, branch, worktree } = await withWorktree(t);
|
||||
|
||||
const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]);
|
||||
|
||||
assert.equal(code, 0, json.error?.message);
|
||||
assert.equal(json.data.removed, true);
|
||||
assert.equal(existsSync(worktree), false, '工作樹要真的不見了');
|
||||
assert.equal(
|
||||
repo.git('branch', '--list', branch).trim().replace(/^\*?\s*/, ''),
|
||||
branch,
|
||||
'本機分支要留著:不佔什麼空間,而使用者還會回頭看那段歷史',
|
||||
);
|
||||
});
|
||||
|
||||
test('回報的路徑就是 branch-prep 建的那一條', async (t) => {
|
||||
const { home, branch, worktree } = await withWorktree(t);
|
||||
|
||||
const { json } = await run(home, ['--repo', REPO, '--branch', branch]);
|
||||
|
||||
assert.equal(json.data.worktree, worktree, '兩支腳本要從同一個輸入推導出同一條路徑');
|
||||
});
|
||||
|
||||
// ── 守門:有未提交變更就中止 ───────────────────────────────────────
|
||||
|
||||
test('工作樹裡有未提交的變更時中止,並報出路徑', async (t) => {
|
||||
const { home, branch, worktree } = await withWorktree(t);
|
||||
writeFileSync(join(worktree, 'README.md'), '改到一半的東西\n');
|
||||
|
||||
const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'WORKTREE_DIRTY');
|
||||
assert.match(json.error.message, new RegExp(worktree), '要指名是哪一條路徑,人才找得到它');
|
||||
assert.match(json.error.message, /README\.md/, '也要說出是哪些檔案擋住了');
|
||||
assert.equal(existsSync(worktree), true, '擋下來就不該已經刪掉');
|
||||
assert.equal(existsSync(join(worktree, 'README.md')), true);
|
||||
});
|
||||
|
||||
test('未追蹤的檔案同樣算未提交:它一樣會被刪掉', async (t) => {
|
||||
const { home, branch, worktree } = await withWorktree(t);
|
||||
writeFileSync(join(worktree, 'notes.txt'), '還沒加進版控的筆記\n');
|
||||
|
||||
const { json } = await run(home, ['--repo', REPO, '--branch', branch]);
|
||||
|
||||
assert.equal(json.error.code, 'WORKTREE_DIRTY');
|
||||
assert.match(json.error.message, /notes\.txt/);
|
||||
});
|
||||
|
||||
// ── 冪等 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('工作樹本來就不在時安靜地成功,重跑不會失敗', async (t) => {
|
||||
const { home, branch } = await withWorktree(t);
|
||||
await run(home, ['--repo', REPO, '--branch', branch]);
|
||||
|
||||
const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]);
|
||||
|
||||
assert.equal(code, 0, json.error?.message);
|
||||
assert.equal(json.data.removed, false);
|
||||
assert.equal(json.data.已經不在, true, '要說清楚是本來就不在,不是這次刪的');
|
||||
});
|
||||
|
||||
// ── --dry-run ─────────────────────────────────────────────────────
|
||||
|
||||
test('--dry-run 印出將執行的 git 指令,且不移除任何東西', async (t) => {
|
||||
const { home, branch, worktree } = await withWorktree(t);
|
||||
|
||||
const { code, json } = await run(home, ['--repo', REPO, '--branch', branch, '--dry-run']);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.dryRun, true);
|
||||
assert.deepEqual(json.data.commands, [`git worktree remove ${worktree}`]);
|
||||
assert.equal(
|
||||
json.data.commands.some((c) => c.includes('--force')),
|
||||
false,
|
||||
'絕不 --force:自動執行的東西只能做可逆的事',
|
||||
);
|
||||
assert.equal(existsSync(worktree), true, '試跑不該真的刪掉');
|
||||
});
|
||||
|
||||
test('--dry-run 也照樣把守門的結果說出來', async (t) => {
|
||||
// 試跑印得出漂亮的計畫、實跑卻被擋下來,是最難查的那種落差
|
||||
const { home, branch, worktree } = await withWorktree(t);
|
||||
writeFileSync(join(worktree, 'wip.txt'), '做到一半\n');
|
||||
|
||||
const { json } = await run(home, ['--repo', REPO, '--branch', branch, '--dry-run']);
|
||||
|
||||
assert.equal(json.error.code, 'WORKTREE_DIRTY');
|
||||
});
|
||||
|
||||
// ── flag ──────────────────────────────────────────────────────────
|
||||
|
||||
test('缺 --branch 時指名缺的是哪一個', async (t) => {
|
||||
const { home } = await withWorktree(t);
|
||||
|
||||
const { json } = await run(home, ['--repo', REPO]);
|
||||
|
||||
assert.equal(json.error.code, 'MISSING_FLAG');
|
||||
assert.match(json.error.message, /--branch/);
|
||||
});
|
||||
|
||||
test('--repo 格式不是 owner/name 時失敗', async (t) => {
|
||||
const { home } = await withWorktree(t);
|
||||
|
||||
const { json } = await run(home, ['--repo', 'tea-sdlc', '--branch', 'feat/mine/main']);
|
||||
|
||||
assert.equal(json.error.code, 'BAD_REPO');
|
||||
});
|
||||
|
||||
test('路徑上是一個獨立的 clone 時也擋下:對它下 worktree remove 只會得到一句 git 的原始錯誤', async (t) => {
|
||||
const { repo, home, branch, worktree } = await withWorktree(t);
|
||||
repo.git('worktree', 'remove', worktree);
|
||||
mkdirSync(join(worktree, '.git'), { recursive: true });
|
||||
|
||||
const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'NOT_A_WORKTREE');
|
||||
assert.equal(existsSync(join(worktree, '.git')), true);
|
||||
});
|
||||
|
||||
test('推導出來的路徑上是別人的東西時不碰它', async (t) => {
|
||||
const { repo, home, branch, worktree } = await withWorktree(t);
|
||||
repo.git('worktree', 'remove', worktree);
|
||||
mkdirSync(worktree, { recursive: true });
|
||||
writeFileSync(join(worktree, '別人的東西.txt'), 'x\n');
|
||||
|
||||
const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'NOT_A_WORKTREE');
|
||||
assert.equal(existsSync(join(worktree, '別人的東西.txt')), true);
|
||||
});
|
||||
@@ -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 })),
|
||||
};
|
||||
});
|
||||
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