diff --git a/README.md b/README.md index d1b5e5a..76894d2 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # tea-sdlc 以 [tea](https://gitea.com/gitea/tea) 與 Gitea REST API 驅動 **SDLC 全流程**的跨平台指令組。 -把規劃、分析、實作、修正、整併、工時回報六個階段,固定成可重複、可被任何 coding agent 執行的流程。 +把規劃、分析、實作、修正、整併與報表停用流程,固定成可重複、可被任何 coding agent 執行的流程。 - **流程正本只有一份**:平台中立 markdown 放在 `prompts/`,改規則不會出現各平台版本分歧。 - **副作用集中**:所有對 Gitea 與 git 的呼叫下沉到 `scripts/` 的零相依 Node 腳本,統一 JSON 輸入輸出。 @@ -21,7 +21,7 @@ | `/sdlc-feat` | 領取工作包、開分支、逐項實作並開 PR | | `/sdlc-fix` | 處理 PR 上的留言;收到議題編號則交棒給 `/sdlc-feat` | | `/sdlc-sync` | 把散落在留言裡的決策整併回議題描述 | -| `/sdlc-report` | 產出週/月/年工時報表 | +| `/sdlc-report` | 週報/月報/年報目前不可用,回傳 `REPORT_UNAVAILABLE` | --- @@ -54,9 +54,7 @@ tea-sdlc/ | 需求 | 用途 | 缺了會怎樣 | | --- | --- | --- | | Node.js ≥ 20 | 執行 `tea-sdlc` 與 `scripts/` | 連 `tea-sdlc` 都跑不起來 | -| git | 分支與 commit 操作 | `install` 照樣把轉接檔裝好,只在輸出裡列出缺的東西;流程指令中止並印出安裝指引 | -| [`tea`](https://gitea.com/gitea/tea) 並已登入 | Gitea 議題、標籤、Milestone、留言、工時 | 同上;登入用 `tea login add` | -| 目標 repo 已開啟時間追蹤 | 工時碼錶 | 流程指令中止,並指出 Settings → Advanced Settings → Enable Time Tracker | +| [`tea`](https://gitea.com/gitea/tea) 並已登入 | Gitea 議題、標籤、Milestone、留言 | 同上;登入用 `tea login add` | | 帳號對目標 repo 的 issues unit 有寫入權 | 建立與更新議題 | 流程指令中止;Gitea 的 unit 權限獨立於 push 權限 | --- diff --git a/prompts/sdlc-analyze.md b/prompts/sdlc-analyze.md index a9ed1f8..09c9160 100644 --- a/prompts/sdlc-analyze.md +++ b/prompts/sdlc-analyze.md @@ -3,307 +3,37 @@ description: 僅由 /sdlc-analyze 指令叫用。對一顆需求議題執行可 # sdlc-analyze -對一顆需求議題執行可行性檢查,把疑點一題一題問到雙方有共識,再把共識變成一批工作包議題。 - -分成三段:**可行性分析**到共識摘要為止,除了起錶之外完全不寫入 Gitea;使用者看過摘要 -點頭之後,才進入**產生工作包**建立議題;最後**排上時程與看板**,把相依、截止日、 -Milestone、看板與人天估算補上。 - -這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。 - ## 輸入 -一個需求議題編號。 - -## 計時範圍 - -錶起在**它分析的那顆需求議題**上:在「起錶」那一步起,在「停錶並回報」那一步停, -涵蓋整道 /sdlc-analyze。 - -起點放在第一段開頭,因為分析最耗時的正是共識之前那一段——四類疑點逐題問到收斂。 -從第二段才起錶的話,那段時間永遠是零,而報表上「分析不花時間」會直接餵給下一次估算。 - -錶已經跑在同一顆議題上時起錶什麼都不做,所以 plan 接著跑 analyze 不會把累積的時間 -切成兩段。**錶不跨階段跑**,也**不碰別顆議題上的錶**。 - -## 〔可委派〕的意思 - -標題後綴 `〔可委派〕` 的步驟只在意結果:**你的環境若能把工作交給子代理,就交出去, -只把結果帶回來;不能就自己做。** 沒有這個後綴的步驟一律自己做。 - -怎麼挑、為什麼這樣挑,見 `references/delegation.md`——判準只有那一份,這裡不複述。 +一個需求議題編號。先用 `scripts/issue-extract.js` 讀取結構化內容;先看 `未處理留言數`。若有留言,直接走 `/sdlc-sync` 的流程,完成後自動接回這裡;不要要求使用者重打指令。接回前重新抽取一次拿到更新後的描述;若使用者不整併,繼續並在摘要註明。 ## 第一段:可行性分析 -### 1. 讀議題 +依序讀取並逐條對照: -``` -node scripts/issue-extract.js --repo --index <編號> -``` +1. `references/feasibility-architecture.md` +2. `references/feasibility-logic.md` +3. `references/feasibility-data.md` +4. `references/feasibility-schedule.md` -拿到的是結構化欄位,不必再讀整份議題全文。 +能從程式碼查證的事項自行查證;只有需要使用者決策的事項才提問。架構、邏輯、資料、時程四類依序完成,每次只問一題,每題提供建議與手動輸入。最後輸出共識摘要、變更假設、未決事項與人天估算;摘要只印終端,不寫入 Gitea。 -**先看 `未處理留言數`。** 只要不是 0,就代表議題描述可能是過期的——留言裡有決策還沒被 -整併回描述。這時**先停下來**告訴使用者有幾則未整併的留言,問他要不要現在整併。 - -要整併的話**直接走 `/sdlc-sync` 的流程**(`prompts/sdlc-sync.md`),做完**自動接回這裡**: -重新抽取一次拿到更新後的描述,再往下走。**不要要求使用者重打指令**——他已經說要整併了。 - -使用者選擇不整併就繼續,但要記下這件事,並在共識摘要裡註明「分析基於未整併留言前的描述」。 - -### 2. 起錶 - -議題確認存在之後,在**這顆需求議題**上起錶: - -``` -node scripts/timer.js --repo --index <需求議題編號> --dry-run -``` - -確認無誤後拿掉 `--dry-run` 再跑一次。錶已經跑在同一顆上時它什麼都不做,重跑不會把 -已經累積的時間切成兩段。 - -錶跑在別顆議題上時會被擋下(`STOPWATCH_ON_OTHER_ISSUE`)。**照實告訴使用者是哪一顆, -請他自己去停**,不要代勞:那一段時間該記在哪顆議題上只有他知道。順帶說明停錶不會動到 -任何既有的工作樹——碼錶只管時間、工作樹只管檔案。 - -### 3. 對四份清單列出疑點 〔可委派〕 - -依序讀這四份規則正本,逐條對照議題內容: - -1. `references/feasibility-architecture.md` — 架構:放錯 repo、循環相依、穿越邊界。 -2. `references/feasibility-logic.md` — 邏輯:既有功能是不是已經做過同一件事。 -3. `references/feasibility-data.md` — 資料:schema 變更、遷移、交易邊界。 -4. `references/feasibility-schedule.md` — 時程:相依鏈最長路徑、未知數最大的一項。 - -每一條檢查若在議題裡找不到答案,就轉成一個問題。**能在程式碼裡查證的就自己去查, -不要拿去問使用者**——把問題留給只有人能回答的事。 - -### 4. 逐題問到共識 - -**一次問一題。** 問完等使用者回答,再問下一題,讓他能看著前一題的答案回答下一題。 -不要一次丟出五個問題,也不要把多個問題包成一題的多個選項。 - -順序固定為**架構 → 邏輯 → 資料 → 時程**,前一類的問題全部清空才進入下一類。 -前面的答案常常會讓後面的問題消失或改寫;每問完一題,重新檢視剩下的問題還成不成立。 - -每一題固定給兩個選項: - -- **建議** — 你的答案,附上理由。理由要寫「為什麼是這個」,不是複述問題。 -- **手動輸入** — 讓使用者自己寫。任何一題都必須能手動作答,不被選項限制。 - -問題本身要具體到能用一句話回答。問不出收斂答案的問題,多半是問題本身太大,拆開再問。 - -### 5. 輸出共識摘要 - -全部問完後,輸出一份摘要讓使用者做最後確認,內容包含: - -- **每一類的結論** — 架構/邏輯/資料/時程各自問出了什麼,逐條列出「問題 → 答案」。 -- **改變了什麼** — 分析過程中翻掉或修正了需求議題裡的哪些假設。 -- **仍然未決的事** — 問了但沒有答案、或使用者明確說「之後再說」的事。 -- **人天估算** — 每一項的估算與最沒把握的那一項。 - -摘要只印在終端,**不寫回議題、不建立任何東西**。使用者看過點頭之後,才進入下一段。 +使用者確認共識摘要後,**先列出要交付或驗收的項目,逐項向使用者確認**。使用者未確認或拒絕時,立即停止,不建立工作包、不排程、不寫入後續資料。 ## 第二段:產生工作包 -**使用者對共識摘要點頭之後才開始。** 摘要沒有經過確認就不要往下走。 +確認交付/驗收項目後,依共識切出可獨立完成的工作包,套用 `templates/work-package-issue.md`。每顆工作包的待辦與驗收都要可逐項勾選;對應已確認交付項目的待辦置於第一項。工作包整體也依交付優先排序,但不得違反先決關係。 -### 6. 切出工作包 +每顆工作包先以 `scripts/issue-create.js --dry-run` 檢查,再移除旗標實跑。只使用既有標籤。建立後回報編號、標題與網址。 -把需求切成幾顆工作包。一顆工作包是**開發者拿了就能動手、做完有明確結果**的單位: -它有自己的驗收標準,做完能單獨被檢視,不必等別的工作包一起才看得出成果。 +## 第三段:排程 -切的依據是第一段問出來的共識,特別是時程清單那份暫定拆法——那本來就是這一段的草稿。 +以 `startDate`、`days` 與 `depends` 組成計畫檔,執行 `scripts/schedule.js` 計算截止日;依序用 `issue-link.js`、`issue-update.js` 與 `project-add.js` 補上既有相依、Milestone、看板、截止日與人天估算。各腳本先 dry-run,再實跑。日期與人天是排程資料,不是耗時統計。 -**標題格式為「{動詞}{名詞}」**,例如「建立工作包的抽取契約」、「產生圖解版總覽網頁」。 -**禁止流水編號與任何無意義代號**(`WP-01`、`任務三`、`第一階段`):命名本身就要說明用途, -看標題就知道這顆在做什麼,不必點進去。 - -### 7. 組出每顆工作包的內容 - -套用 `templates/work-package-issue.md`,依序填滿九個段落: - -1. **這個工作包在做什麼** — 一句話。讓人掃過標題與這一行就決定要不要點進來。 -2. **描述** — 從使用者的角度說這顆做完之後什麼事變得可能,不要寫成逐層的實作清單。 -3. **架構圖** — 見下方「架構圖的限制」。 -4. **範圍邊界** — 明列**不做什麼**。這一段的用途是抵抗範圍蔓延,寫得越具體越有用。 -5. **介面契約** — 表格,四欄:介面/產出者/消費者/形狀。讓人知道自己產出的東西誰會消費。 - 這顆不產出對外介面就寫一列「無」,不要留空表。 -6. **待辦** — 巢狀結構:每一項待辦底下掛**它自己的**驗收標準,讓人知道這一項做到什麼程度算完成。 - - ``` - - [ ] 建立共用函式庫 - - [ ] 具名 flag 解析可拒絕未知參數 - - [ ] 單行 JSON 輸出格式固定 - - [ ] 加上前置檢查 - - [ ] 四層各自回傳可區分的錯誤碼 - ``` - - 上層是待辦、縮排一層是該項的驗收,不要再往下巢狀。兩者都用 checkbox,實作時會被逐項勾選。 -7. **整體驗收** — 整顆工作包做完才驗得出來的事,與個別待辦的驗收不重複。 -8. **repo 列表** — 這顆會動到哪些 repo。 -9. **關聯** — 至少要有一行 `需求議題:#<編號>` 指回來源。阻擋、先決與人天估算由後續流程補上。 - -### 8. 先試跑,再寫入 - -每顆工作包各寫一個暫存檔,然後逐顆: - -``` -node scripts/issue-create.js --repo --title "<標題>" --body-file <暫存檔> \ - --labels "<標籤>" --dry-run -``` - -`--dry-run` 會印出將送出的請求、把標籤名稱換成 id,並在標題已存在時如實顯示「實跑會是 -no-op」。確認無誤後拿掉該旗標再跑一次。 - -標籤一樣只能從 `scripts/labels-list.js` 回傳的既有標籤裡挑,**不得自行建立新標籤**。 - -中斷後重跑不會產生重複工作包:`issue-create` 以標題查重,發現同名議題就回傳既有那一顆 -並把 `created` 設為 `false`。 - -### 9. 回報 - -列出每顆工作包的編號、標題與網址。不要把議題內容再貼一次。 - -**這裡不停錶**——指令還沒跑完,第三段還要排時程。停錶在最後的「停錶並回報」那一步。 - -## 第三段:排上時程與看板 - -工作包建好之後,把它們之間的關係與時程補上。做完這一段,看板上呈現的才是真實的 -開發順序,而不是一堆平鋪的議題。 - -### 10. 算出截止日 〔可委派〕 - -把每顆工作包的編號、人天估算與先決關係寫成一份計畫檔: - -```json -{ - "startDate": "2026-09-21", - "workPackages": [ - { "index": 12, "title": "建立共用函式庫", "days": 3 }, - { "index": 13, "title": "建立抽取契約", "days": 2, "depends": [12] } - ] -} -``` - -``` -node scripts/schedule.js --plan-file <計畫檔> -``` - -它依相依關係做拓撲排序,保證**任一工作包的截止日都不早於它的先決**——人工排時程 -最常見的矛盾就是前置工作比後續還晚到期。相依成環時它會直接報錯並指出環上的成員, -那代表拆法有問題,回頭改拆法,不要硬排。 - -日期以日曆日累加,不跳週末也不扣假日。要跳的話自己把 `startDate` 或人天調整過再算。 - -### 11. 逐顆補上關係與時程 - -對每一顆工作包,依序: - -``` -node scripts/issue-link.js --repo --index <編號> --depends <先決編號清單> -node scripts/issue-update.js --repo --index <編號> \ - --milestone "<既有 Milestone 名稱>" --due-date --estimate-days <人天> -node scripts/project-add.js --repo --index <編號> --project "<看板名稱或網址>" -``` - -三支都先用 `--dry-run` 看過再實跑。三支都是冪等的:相依已存在就跳過、已在看板上就不重發、 -估算沒變就不改 body。 - -**Milestone 與看板都只掛既有的。** 指到不存在的 Milestone 會中止並列出可選項目; -看板名稱靠掃最近 50 筆議題反查 id,反查不到就會請你直接貼專案網址(結尾即 id)。 -本流程不建立 Milestone,也不建立專案。 - -### 12. 產生分析版的圖解總覽 〔可委派〕 - -排程完成後重新抽取需求與全部工作包,組成 `schemaVersion: 1` 的需求級 JSON。 -工作包依賴與截止日必須來自重新抽取的實際資料,不使用模型記憶中的暫定值。 -執行: - -``` -node scripts/overview-render.js --input --output --manifest -``` - -HTML 內重新產生 SVG 與 HTML/CSS 視圖;不能直接搬用議題 Mermaid。預覽優先使用平台 -能力,否則啟動短命 Node server 供瀏覽器截圖。產生 full-page 與局部圖 PNG 後執行: - -``` -node scripts/issue-assets.js --repo --index <需求議題編號> --manifest -``` - -截圖全部上傳成附件,需求議題正文只保留附件索引。只有真正可用的 preview URL 才使用 -既有 `--overview-url`;不可把 `.tmp/` 或 `0.0.0.0` 寫回 Gitea。 -產出 HTML 可委派;預覽截圖、附件上傳與任何 `issue-assets`/`issue-update` 寫回都不委派, -由主流程執行並處理錯誤。 -若平台 preview 不可用,執行 `node scripts/overview-capture.js --backend auto`; -它依序嘗試 Firefox,再嘗試 `rsvg-convert`/`resvg`。所有 backend 都不可用時必須 -停止並保留 `.tmp/` 的 HTML/SVG,不得宣稱截圖完成。 -### 13. 停錶並回報 - -回報之前先停錶,這一段計時到此為止: - -``` -node scripts/timer.js --repo --index <需求議題編號> --stop -``` - -它**只停這一顆**上的錶,與「起錶」那一步起的是同一顆。錶本來就沒在跑不算失敗 -(`碼錶已停` 會是 `false` 並附一句說明),回報照樣做完。 - -列出每顆工作包的編號、標題、截止日與所屬 Milestone,並指出**相依鏈最長路徑**上的那幾顆 -——那條路徑決定整體交期。 - -## 已知限制:人天估算只寫得進 body - -Gitea 1.27 的 API 沒有任何請求定義接受 `time_estimate`,該欄位只出現在議題的回應裡。 -也就是說**議題的估算欄位無法由 API 寫入**,只能靠人在網頁上填。 - -因此 `issue-update --estimate-days` 只把估算寫成議題 body 裡人類可讀的一行 -(`估算人天:N`,放在「關聯」段落)。之後 `sdlc-report` 要比對估算與實際工時時, -讀的也是這一行。 - -## 架構圖的限制 - -圖表以抽象 `kind`、`direction`、`nodes`、`edges` 保存。renderer 產生議題 Mermaid -與 HTML SVG;不能讓模型直接維護兩套圖表語法。節點超過 12、文字超過 8 字或無法 -安全轉換時,拆圖或記錄 `omitted` 原因。 ## 邊界 -- **共識摘要之前不對 Gitea 寫入任何內容**:不建議題、不改描述、不貼標籤、不留留言。 - **碼錶除外**——它記的是工時,不是內容;而分析最耗時的正是共識之前那一段,不從那裡 - 起錶,那段時間就永遠是零。 -- 第二段只建立工作包議題。不建相依、不掛 Milestone、不加看板、不寫人天估算——那是第三段的事。 -- 第三段只掛既有的 Milestone 與看板。不自行建立標籤、Milestone 或專案看板。 -- 不修改使用者的專案檔案。查證既有功能時只讀不寫。 -- 不替使用者決定他沒回答的事。問不到答案就進「仍然未決的事」。 -- 計時只動這顆需求議題:在它上面起錶、在它上面停錶。**不停別顆議題上的錶**, - 被別顆的錶擋下時交還給使用者決定,不繞過去。 -- 不關閉或刪除任何既有議題。 -# Artifact 交接與視圖 - -完成第三段排程後,依 `references/artifact-contract.md` 重新抽取需求與全部工作包, -組成一份 `schemaVersion: 1` 的 JSON 暫存檔。這份 JSON 是 HTML renderer 的輸入, -不是 Gitea 的第二份正本。 - -先執行: - -``` -node scripts/overview-render.js --input --output --manifest -``` - -renderer 驗證失敗時,不截圖、不上傳、不更新議題,保留 `.tmp/` 的輸入與錯誤供修正。 -HTML 必須自包含,不依賴 CDN;圖表由抽象節點/邊資料重新產生 SVG,不能直接搬用議題 -Mermaid。公式是可選欄位,單一公式無法渲染時保留原文並標示未渲染。 - -預覽優先使用執行環境提供的預覽能力。沒有預覽能力時,使用 `0.0.0.0` 綁定的 -ephemeral Node 靜態伺服器,只為瀏覽器截圖,完成後一定停止。產生 full-page 與所有 -局部圖 PNG,為每張圖填入 manifest 的角色、固定檔名與 SHA-256。 - -截圖全部是需求議題附件,不嵌入圖片;執行: - -``` -node scripts/issue-assets.js --repo --index <需求議題編號> --manifest -``` - -這支腳本負責 hash 重用、附件上傳與「圖解版總覽附件」索引的冪等更新。上傳或索引 -更新失敗就是流程失敗。只有預覽平台提供真正可用的 URL 時,才另外使用既有的 -`--overview-url`;不可把 `.tmp/` 或 `0.0.0.0` 寫回 Gitea。 +- 不產生 HTML、SVG、manifest、截圖、附件或任何平台 preview。 +- 不建立 Milestone 或專案看板。 +- 不修改使用者專案檔案。 +- 不關閉或刪除既有議題。 +- 共識摘要與交付確認前不建立任何工作包。 diff --git a/prompts/sdlc-feat.md b/prompts/sdlc-feat.md index 6a1a999..0435757 100644 --- a/prompts/sdlc-feat.md +++ b/prompts/sdlc-feat.md @@ -1,375 +1,16 @@ name: sdlc-feat -description: 僅由 /sdlc-feat 指令叫用。領取一顆工作包、備妥工作樹、起錶,逐項實作並勾選待辦,最後分批提交並開立 PR。 +description: 僅由 /sdlc-feat 指令叫用。領取工作包、開分支、逐項實作並開 PR。 # sdlc-feat -拿一顆工作包,從領取到開出 PR。 +輸入一個工作包議題編號。若抽取結果有 `未處理留言數`,直接走 `/sdlc-sync` 的流程,完成後自動接回這裡;不要要求使用者重打指令。接回前重新抽取一次拿到更新後的描述。 -第一段**領取與開工準備**:把工作包安全地認領下來,備妥一棵屬於它的工作樹,然後開始計時。 -這一段不改任何一行程式碼——它只負責讓後面的實作有個乾淨的起點。 +依工作包 `repos` 逐一準備 worktree;不在主工作區切換分支。逐項完成待辦並立即勾選對應驗收。交付優先項目位於待辦第一項時先完成;仍須遵守工作包的依賴與範圍邊界。 -第二段**逐項實作**:一項一項把待辦做完並即時勾選,讓議題頁的進度條隨時反映真實狀態。 - -第三段**提交與開立 PR**:把變更整理成讀得懂的歷史,開出 PR,停錶。 - -這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。 - -## 輸入 - -一個工作包議題編號。使用者直接給的,或 `/sdlc-fix` 收到議題後交棒過來的—— -兩者一樣處理,**不要因為是交棒來的就要求他再打一次指令**。 - -## 〔可委派〕的意思 - -標題後綴 `〔可委派〕` 的步驟只在意結果:**你的環境若能把工作交給子代理,就交出去, -只把結果帶回來;不能就自己做。** 沒有這個後綴的步驟一律自己做。 - -怎麼挑、為什麼這樣挑,見 `references/delegation.md`——判準只有那一份,這裡不複述。 - -## 第一段:領取與開工準備 - -### 1. 讀工作包 - -``` -node scripts/wp-extract.js --repo --index <編號> -``` - -拿到的是結構化欄位:待辦與它自己的驗收、範圍邊界、介面契約、相依、repo 列表。 -不必再讀整份議題全文。 - -**先看 `未處理留言數`。** 只要不是 0,就代表議題描述可能是過期的——留言裡有決策還沒被 -整併回描述。這時**先停下來**告訴使用者有幾則未整併的留言,問他要不要現在整併。 - -要整併的話**直接走 `/sdlc-sync` 的流程**(`prompts/sdlc-sync.md`),做完**自動接回這裡**: -重新抽取一次拿到更新後的描述,再往下走。**不要要求使用者重打指令**——他已經說要整併了。 - -使用者選擇不整併就繼續,但要記下這件事,並在最後的 PR 描述裡註明「實作基於未整併留言前的描述」。 - -**再看 `相依.depends`。** 裡面還有沒關閉的議題,代表這顆的前置還沒做完。照樣先說出來, -讓使用者決定要不要現在做。 - -### 2. 領取工作包 - -先試跑,看清楚會做什麼: - -``` -node scripts/claim.js --repo --index <編號> --dry-run -``` - -確認無誤後拿掉旗標再跑一次。放行時它會設 assignee、貼「進行中」標籤——這兩件事一起 -構成領取鎖。**錶不在這一步起**:它等工作樹建好之後才起(第 6 步)。工作樹建立失敗會 -中止整個領取,錶要是先起了,使用者就被計了一段什麼都沒做的時間。 - -領取鎖有四種狀態,三種擋、一種放行。被擋下來時**不要繞過去**,照著錯誤碼告訴使用者 -發生什麼事、下一步是什麼: - -| 狀態 | 錯誤碼 | 下一步 | -| --- | --- | --- | -| 別人已經認領這顆 | `CLAIMED_BY_OTHER` | 改領別顆,或先跟對方確認 | -| 你的錶已經跑在這顆上 | `STOPWATCH_ON_THIS_ISSUE` | 這顆你正在做;要重新計時請先手動停錶 | -| 你的錶跑在別的議題上 | `STOPWATCH_ON_OTHER_ISSUE` | 多半是忘了停上一顆;先去停掉再回來 | - -被錶擋下來時**要順帶說明停錶不會動到既有的工作樹**:碼錶只管時間、工作樹只管檔案。 -不講清楚,使用者會以為停錶等於放棄那顆工作包,於是寧可不停——工時就記到別顆去了。 -| 沒有鎖 | —— | 放行。自己已認領但沒起錶也算沒有鎖,那正是中斷後重跑的情形 | - -**別顆議題上的錶一律由使用者自己停。** 哪一段時間該記在哪顆議題上只有他知道,代勞會把 -工時記錯地方。每道指令停掉的只有自己起的那一支——這一道停在「開 PR 並停錶」那一步。 - -鎖以外還有一個前置條件:repo 上要有「進行中」標籤。缺了會得到 `LABEL_NOT_FOUND`, -請使用者自己去建立——**不要自己建**,標籤體系不該在多個 repo 之間長出雜草。 - -### 3. 問來源分支 - -**一次問一題。** 新分支要從哪裡長出來,只有使用者知道,不要替他決定。 -給兩個選項,並附上你判斷的理由: - -- **建議** — 你的答案。多數情況是開發分支(`master`/`main`/`develop`); - 但若這顆工作包明顯是某個既有功能分支的一部分,就建議那一支,並說明為什麼。 -- **手動輸入** — 讓使用者自己填分支名。 - -不論哪一種,來源分支都必須**已經在遠端上**:工作樹的起點一律取自 `origin/{來源分支}`。 - -### 4. 把議題標題翻成英文 〔可委派〕 - -分支名的中段要用英文,中文會讓 CI 與 URL 出問題。把工作包議題的標題翻成 -**小寫英文 kebab、40 字元以內**,例如「建立工作包的抽取契約」→ `wp-extract-contract`。 - -翻譯要保留原意而不是逐字直譯,寧可用一個更短的說法,也不要把長句截斷成看不懂的字串。 - -### 5. 備妥工作樹 - -工作樹開在**工作包的 `repos` 列出的那些 repo** 上,不是開在本 plugin 的目錄裡。 -`repos` 只有一顆就用那一顆;**有多顆時逐一確認**要在哪幾個開分支, -再對每一個各跑一次 `branch-prep`,分支名在每個 repo 都相同。 - -``` -node scripts/branch-prep.js --repo --path <目標專案路徑> \ - --source <來源分支> --slug <英文-kebab> [--type feat] --dry-run -``` - -`--type` 只在來源是開發分支時要給(`feat`/`fix`/`chore`…);從功能分支長出時, -類型與需求描述沿用來源,不必也不能再指定。 - -試跑會印出將執行的 git 指令、算出來的分支名與工作樹路徑。確認無誤後拿掉旗標再跑一次。 - -**不在原地切換分支,一律開一棵獨立的工作樹。** 每顆工作包有自己的目錄、自己的建置 -產物、自己的未提交變更,彼此看不見對方。這件事對 agent 特別重要:它是非同步的, -可能在分支已經被切走之後才去讀檔,而它**不會察覺**自己讀到的是別顆工作包的內容—— -產出看起來完全合理,只是接錯了上下文。 - -**工作樹一律建立,沒有例外。** 建不起來就照實中止,**不要改成在原地切分支**: -使用者會以為自己在隔離環境裡,其實在原地改。 - -工作樹路徑由 `owner/repo/分支名` 推導而得,印在輸出的 `worktree` 欄位。 -**後面幾段的實作、測試與提交都在那棵工作樹裡做**,不要回到主工作區動手。 - -它保證三件事: - -- **起點一律是遠端的來源分支**(`origin/{來源分支}`),不是本機同名分支——後者可能 - 落後好幾天。遠端沒有那一支時得到 `SOURCE_NOT_FOUND`,把訊息念給使用者,讓他決定 - 是先把來源分支推上去,還是改指定一個別的來源——**不要自己換一個**。 -- **目標分支已經存在時接上去而不是蓋掉**;工作樹已經在了就沿用,不動裡面還沒提交的東西。 -- **失敗時不留半成品**:不會出現有分支沒工作樹、或有工作樹沒分支的狀態。 - -推導出的路徑被別的東西佔住時(`WORKTREE_PATH_TAKEN`,多半是別的 clone 留下的), -把路徑念給使用者,請他確認裡面沒有還沒保存的東西再移除——**不要自己刪**。 - -工作樹是乾淨的:**沒有安裝依賴,也沒有任何建置產物**,`.env` 這類機密檔案更不會被 -複製過去。把輸出的 `提示.安裝指令` 念給使用者,機密檔案請他自己放一份。 - -### 6. 起錶 - -工作樹建好之後才起錶: - -``` -node scripts/timer.js --repo --index <編號> --dry-run -``` - -確認無誤後拿掉旗標再跑一次。錶已經跑在這顆議題上時它什麼都不做——那正是中斷後重跑 -的情形,重新起錶會把已經累積的時間切成兩段。 - -### 7. 回報 - -印出一份開工前的現況,不寫回議題: - -- 工作包標題與網址、這一顆有幾項待辦 -- 認領結果(是否本來就是自己的)、碼錶已起 -- 來源分支、新分支名、分支是新建還是接上既有 -- 工作樹路徑,以及它是乾淨的、要先跑哪一行安裝指令 -- 未處理留言數與未關閉的先決議題(若有) - -## 第二段:逐項實作 - -### 8. 認出語言,讀規則正本 - -改任何一個檔案之前,先依專案檔認出這是什麼語言,再讀兩份規則正本: - -- `references/coding-standards.md` — 分層判定與各層要寫什麼註解 -- `references/comment-styles.md` — 該語言的註解格式 - -規則以那兩份為準,這裡不複述——抄過來就會有兩份各自演化的規則。只強調兩件最常被跳過的: -**認不出語言就停下來問、不要猜**,以及**規則只存在於本 plugin 裡**, -不寫進目標專案的任何檔案。 - -屬性的資料範例**優先從 MCP 取得**;取不到就以邏輯推理,並照 `comment-styles.md` 的寫法 -在註解裡註明「由邏輯推理、未經驗證」。這句註明不能省,否則後面的人會照著沒對過的格式寫解析。 - -### 9. 一項一項做 - -**改的是工作樹裡的檔案**,路徑就是 `branch-prep` 印出來的 `worktree`,不是主工作區—— -主工作區可能停在別的分支上,在那裡動手會把改動落到別顆工作包的分支去。 - -依 `wp-extract` 給的 `待辦` 順序做。每一項的做法: - -1. 讀它底下的 `驗收`——那是「這一項做到什麼程度算完成」的定義。 -2. 實作,照 `coding-standards.md` 的分層與註解規範。 -3. 這一項的驗收都成立了,才算完成。 - -**過程不打斷。** 不要每做完一項就問一次「可以繼續嗎」——二十項待辦不該按二十次同意。 -只印進度,例如 `[3/12] 已完成:解析九個段落`。 - -真正需要停下來問的只有三種:語言認不出來、待辦的意思有歧義、做下去會超出工作包的 -`範圍邊界`。除此之外一路做完。 - -### 10. 做完一項就勾一項 - -``` -node scripts/issue-update.js --repo --index <編號> \ - --tick '' --section 待辦 -``` - -`--tick` 收的是抽取契約交出的**那一整行 `raw`**,逐字包含縮排;它只把那一行的方框換成 -已勾,議題其餘部分一字不動。待辦與它底下的驗收各自是一行,各勾各的。 - -`--section` 是那一項所在的段落:勾 `待辦` 裡的項目就給 `待辦`,勾 `整體驗收` 就給 -`整體驗收`。**一定要給**——兩個段落常有一模一樣的一句話,不給就分不出要勾哪一個。 - -**不要自己拼那一行**,一律用 `wp-extract` 給的 `raw`。四種擋下來的情況都照實說,不要繞過去: - -| 錯誤碼 | 意思 | 下一步 | -| --- | --- | --- | -| `RAW_NOT_FOUND` | 議題上找不到這一行 | 手上的抽取結果過期了(議題被改過);重跑 `wp-extract` 再試 | -| `RAW_AMBIGUOUS` | 這一行在同一個段落裡出現不只一次 | 分不出要勾哪個;請使用者把重複的那幾項改寫成看得出差別的說法 | -| `NOT_A_CHECKBOX` | 議題上那一項沒有方框 | 請使用者把它補成 `- [ ] …`;**不要自己改寫議題** | -| `SECTION_NOT_FOUND` | `--section` 的段落不存在 | 對照 `wp-extract` 的輸出確認段落名稱 | - -**不要為了勾選在議題上留留言。** 勾選改的是 body,進度條自己會動;逐項留言會把議題洗版, -reviewer 得從一堆「已完成第 N 項」裡找真正的討論。 - -### 11. 中斷後重跑 - -進度完全由 Gitea 上的勾選狀態推導,**不看任何本機檔案**。重跑這一段時: - -1. 重新 `wp-extract`,看 `待辦` 裡哪些 `done` 已經是 `true`。 -2. 從第一個還沒勾的接下去做。 -3. 已經勾過的項目再 `--tick` 一次是安靜的 no-op(回傳 `已經勾過: true`,不發 PATCH), - 所以不確定某一項有沒有勾到時,直接再勾一次即可,不必先查。 - -### 12. 回報 - -全部待辦完成後印一份小結,不寫回議題: - -- 幾項待辦、幾項驗收,全部勾選完成 -- 改了哪些檔案,各屬於哪一層 -- 有沒有待辦因為 `範圍邊界` 而被刻意不做 -- 語言與註解格式用的是哪一份對照 -- **哪些資料範例是推理來的**(MCP 取不到的那些),讓 reviewer 知道哪幾個格式還沒人對過 - -## 第三段:提交與開立 PR - -### 13. 分批提交 〔可委派〕 - -全部待辦都勾完之後才進這一段。變更依類型分批。 - -委派的是**方案計算**:變更分成哪幾批、每一批收哪些檔案、各自的 `--type` 與描述怎麼寫。 -**實際提交不委派**——底下那支 `commit-split.js` 由主流程執行(判準第四條)。 - -``` -node scripts/commit-split.js --path <工作樹路徑> --type feat \ - --subject '<繁中描述>' [--scope <功能名>] --dry-run -``` - -`--path` 給的是第一段建出來的那棵**工作樹**——commit 要落在它的分支上。 - -`--type` 是**這次程式碼變更**的類型(`feat`/`fix`/`refactor`…);測試、文件與設定檔 -由腳本自己認出來,各自成批,不必也不能指定。`--body` 寫「為什麼這樣做」,那一段會接在 -每一顆 commit 的首行之後——本 repo 的歷史靠它讀得懂。 - -某一批提交失敗時,錯誤會列出**前面已經建立的那幾顆 commit**。修掉原因之後重跑即可, -已建立的不會重複;不要自己去回捲歷史。 - -`--scope` 只在某一批有多個檔案時才需要:單檔那批的 scope 就是檔名。試跑會印出將建立的 -每一顆 commit 與它各自的檔案,確認無誤後拿掉旗標再跑一次。 - -**描述用繁體中文。** 日後回顧時看得懂的是中文;夾雜英文的專有名詞(函式名、旗標名) -保留原文即可。 - -一次變更橫跨兩個不相干的功能時,用 `--files` **分兩次跑**: - -``` -node scripts/commit-split.js ... --files scripts/claim.js,test/claim.test.js -``` - -一顆 commit 的描述只說得清楚一件事,硬湊在一起就失去了分批的意義。 - -### 14. 寫 PR 描述 - -固定八個段落,順序不能換——reviewer 每次都在同一個位置找到要找的資訊: - -1. **摘要** — 這個 PR 做完之後,什麼事變得可能。 -2. **需求議題** — `#<編號>`。 -3. **工作包議題** — `#<編號>`。 -4. **變更內容** — 改了什麼。commit 一覽加上新增/修改的檔案。 -5. **設計重點** — 為什麼這樣做。取捨與理由,不是實作步驟的複述。 -6. **解決的問題** — 這次修掉了什麼。有具體觸發條件的就寫出來。 -7. **影響的功能** — 誰會被影響、既有行為有沒有改變。 -8. **測試結果** — 見下。 - -**「測試結果」放實際跑過的輸出**,原樣貼上,不要改寫成「已測試通過」——那句話看不出 -跑過什麼,reviewer 沒辦法據以判斷。沒有自動化測試時,寫出 reviewer 自己能重現的手動 -驗證步驟(跑什麼指令、看到什麼算對)。 - -`pr-create` 會擋下缺段落、順序不對、以及測試結果只有空話的描述。被擋下來時**補真的內容**, -不要為了通過而拼湊。 - -### 15. 開 PR 並停錶 - -``` -node scripts/pr-create.js --repo <目標專案 owner/name> --head <分支名> \ - --base <來源分支> --body-file <描述檔> \ - --issue-repo <工作包議題的 owner/name> --index <工作包編號> --dry-run -``` - -`--repo` 是**程式碼所在的 repo**(PR 開在那裡),`--issue-repo` 是**工作包議題所在的 -repo**(錶停在那裡)。兩者常常不是同一個——議題在需求的 repo,程式碼在 `repos` 列的 -那幾個。同一個 repo 時 `--issue-repo` 可以省略。 - -`--base` 就是第一段問到的那支來源分支,要明講——腳本不替你猜 `master` 還是 `main`。 -標題由腳本設為分支名,不必也不能另外指定。 - -順序是**先開 PR 再停錶**,而且 PR 沒開成就不停錶——工時要記在真的有做事的那段時間上。 -錶本來就沒在跑不算失敗(`碼錶已停` 會是 `false` 並附一句說明),PR 仍然開出去了。 - -重跑不會開出第二顆 PR:同一個 head 已經有開著的 PR 就回傳它(`created` 為 `false`), -然後照樣停錶——那一步可能正是上次中斷的地方。 - -### 16. 回報 - -- PR 的網址與編號、標題(等同分支名),以及它是這次新開的還是接上既有的 -- 建立了哪幾顆 commit -- 碼錶是否已停;沒停的話把腳本回的那句說明一起帶出來 -- 議題上還有沒有沒勾完的待辦(理論上應該沒有;有的話要說出來) - -### 17. 告訴使用者之後怎麼查 - -PR 開出去之後就交給 reviewer 了。**把下面這件事講給使用者聽,不要自己反覆跑**: - -``` -node scripts/pr-watch.js --repo --index -``` - -問一次答一次:PR 狀態、還有幾則留言沒處理、工作樹在哪、裡面有沒有沒提交的東西, -以及固定列舉值的 `suggestedAction`(`run-sdlc-fix`/`cleanup`/`nothing-to-do`/ -`blocked-dirty`)。多久跑一次由使用者自己排(cron 或他自己的循環機制), -本工具不長出排程器。 - -PR 合併或關閉時它會順手清掉那棵工作樹,**本機分支與遠端分支都留著**;工作樹裡還有 -沒提交的東西就會擋下來(`blocked-dirty`),由使用者自己處理。永遠不會被合併也不會被 -關閉的那些 PR,用手動出口清: - -``` -node scripts/worktree-remove.js --repo --branch <分支名> -``` +完成後依既有 commit 分類規則提交,先用 `scripts/pr-create.js --dry-run` 檢查,再實跑開 PR。回報工作包、分支、worktree、完成待辦、commit 與 PR。 ## 邊界 -- 第一段**不改任何一行程式碼**、不勾待辦、不提交、不開 PR——那些是後面幾段的事。 -- 第二段只實作與勾選。**不提交、不開 PR、不停錶**——那是第三段的事。 -- 第三段不改任何一行程式碼。到這裡實作已經結束,要改就回第二段改完再來。 -- **不在主工作區動手。** 第二段與第三段的每一個動作都在 `branch-prep` 建出來的那棵 - 工作樹裡進行,包含跑測試與 `--path`。 -- 不把依賴、建置產物或 `.env` 這類機密檔案複製到工作樹裡,也不做連結—— - 兩棵工作樹共用同一份依賴,正好把工作樹要隔離的東西又接回去。 -- 工作樹建不起來時中止,**不退回原地切分支**。 -- 不把「已測試通過」這種空話寫進 PR 描述,也不為了通過檢查而拼湊內容。 -- 不代替使用者決定 commit 的類型與描述;`--type` 與 `--subject` 都要是這次真的做了什麼。 -- 不把實作規範或註解格式寫進目標專案的任何檔案。 -- 不改與待辦無關的程式碼;順手想修的東西記下來說出來,不要摸進這次的變更裡。 -- 不為了勾選在議題上留留言。 -- **不自動反覆執行 `pr-watch`**,也不因為它建議了 `run-sdlc-fix` 就自己去跑 `/sdlc-fix`—— - 流程只由使用者明確叫用。 -- 不自行建立標籤。缺「進行中」標籤時中止並請使用者建立。 -- 不代替使用者停錶,也不在被鎖擋下時繞過去。 -- 不替使用者決定來源分支。 -- **不寫任何本機狀態檔。** 進度完全由 Gitea 上的 assignee、標籤、碼錶與 git 本身推導, - 換一台機器或換一個 agent 都要能直接接手。 - -## 交接規格閘門 - -讀取工作包後,若它有介面契約,先完成第一個規格待辦:填妥介面、產出者、 -消費者、形狀四欄,並附上該 `interfaceType` 的範例資料。四欄表格與範例資料 -是同一待辦下的兩個驗收;兩者完成前不得進入後續程式實作。 - -資料、架構、排程工作包依其 `type` 先完成對應規格待辦;純內部小型實作可直接 -進入下一個待辦。規格仍寫回工作包議題既有段落,不另建本機正本。 +- 不修改工作包範圍外的檔案。 +- 不切換主工作區分支。 +- 不操作碼錶、不補登工時、不產生耗時統計。 diff --git a/prompts/sdlc-plan.md b/prompts/sdlc-plan.md index 8184dd4..43c294b 100644 --- a/prompts/sdlc-plan.md +++ b/prompts/sdlc-plan.md @@ -3,178 +3,30 @@ description: 僅由 /sdlc-plan 指令叫用。把一段口語需求轉成結構 # sdlc-plan -把使用者給的一段需求,變成一顆結構完整、下游指令讀得動的需求議題。 - -這份檔案是流程正本。各平台的轉接檔只是指回這裡,不要把規則抄過去。 - -## 輸入 - -使用者給的東西可能是下列任一種,也可能三種混用: - -- **自由文字** — 一段口語描述。 -- **規格檔** — 一個檔案路徑,內容是既有的規格或筆記。 -- **議題編號** — 既有議題的編號,用來補充脈絡或作為延伸的起點。 - -先把三種來源讀齊,再開始問問題。規格檔用檔案讀取工具讀;議題編號用 -`scripts/issue-extract.js` 取(若該腳本尚未可用,改用 `scripts/issue-create.js` 以外的 -既有讀取途徑,並在摘要中註明資料來源)。 - -## 計時範圍 - -這份正本把整個 /sdlc-plan 的耗時記成兩段,合起來就是這道指令實際花掉的時間: - -- **議題建立之前** — 在「記下開始時間」記下起點,在「補登規劃時間,然後起錶」補上去。 - 議題還不存在,沒有標的可起錶。 -- **議題建立之後** — 在「補登規劃時間,然後起錶」起錶,在「停錶並回報」停錶。 - -起與停都寫在這一份裡,**錶不跨階段跑**:跑完就去開會而錶跑一整天,報表當場失真。 -反過來,別顆議題上的錶一律不碰——那一段時間該記在哪顆議題上只有使用者知道。 - -## 〔可委派〕的意思 - -標題後綴 `〔可委派〕` 的步驟只在意結果:**你的環境若能把工作交給子代理,就交出去, -只把結果帶回來;不能就自己做。** 沒有這個後綴的步驟一律自己做。 - -怎麼挑、為什麼這樣挑,見 `references/delegation.md`——判準只有那一份,這裡不複述。 +把自由文字、規格檔或既有議題整理成需求議題。 ## 步驟 -### 1. 記下開始時間 - -讀齊輸入、逐項詢問、組出議題內容,往往是整個 plan 最耗時的一段,而它發生在議題建立 -**之前**——那時候沒有標的可起錶。所以先把此刻的時間記下來,等議題建立之後補登上去: +1. 讀齊輸入,列出九個段落中已有依據與缺漏。 +2. 一次問一題補齊缺漏;未獲回答的內容放入「未決事項」,不得自行編造。 +3. 套用 `templates/requirement-issue.md`,填入總覽、背景、目標、非目標、領域名詞表、流程圖、驗收標準、影響範圍與未決事項。 +4. 用 `scripts/labels-list.js` 取得既有標籤,只能選既有標籤。 +5. 寫入前先執行: ``` -node -e "console.log(new Date().toISOString())" +node scripts/issue-create.js --repo --title "<標題>" --body-file <暫存檔> --labels "<標籤>" --dry-run ``` -記下它,一路帶到「補登規劃時間,然後起錶」那一步。**不要憑印象回推**:補登的長度就是 -報表上規劃階段的數字。 +確認內容後移除 `--dry-run` 實跑。重跑以標題查重,不建立重複議題。 -### 2. 讀齊輸入,列出還缺什麼 +## 流程圖限制 -把九個段落逐一對照使用者給的材料,列出哪些段落已經有依據、哪些沒有。 - -### 3. 逐項詢問 - -**一次問一題**,等使用者回答完再問下一題,讓他能看著前一題的答案回答下一題。 - -每一題都附上你的建議與理由,讓使用者多數時候只要點頭;同時保留讓他自己寫答案的餘地。 - -**未獲得答覆的欄位不得自行編造。** 使用者沒說過的目標、沒提過的驗收標準,一個字都不能自己 -填。問不到就放進「未決事項」,那一段本來就是給未決的東西用的。 - -### 4. 組出議題內容 - -套用 `templates/requirement-issue.md`,依序填滿九個段落: - -1. **總覽** — 一句話講完這件事在做什麼,讓非技術的利害關係人不必讀完技術細節。圖解版總覽的 - 連結此時先留空,由後續流程回填。 -2. **背景** — 不超過三行。為什麼現在要做這件事。 -3. **目標** — 可量測。寫得出「怎樣算達成」才算數。 -4. **非目標** — 明列這次不做什麼,用來抵抗範圍蔓延。 -5. **領域名詞表** — 這份需求裡會反覆出現的詞,各給一行定義,讓團隊對同一個詞的理解一致。 -6. **流程圖** — 見下方「流程圖的限制」。 -7. **驗收標準** — 逐條列出,每一條都要能被驗證。 -8. **影響範圍** — 會動到哪些 repo、哪些既有功能。 -9. **未決事項** — 問不到答案、或需要他人拍板的事。 - -### 5. 挑標籤 - -先用 `scripts/labels-list.js --repo ` 取得該 repo 的既有標籤,**只能從這份清單裡 -挑**。找不到合適的就不貼。**不得自行建立新標籤** —— 標籤體系由專案維護者決定,不該在多個 -repo 之間長出雜草。 - -### 6. 先試跑,再寫入 - -把組好的內容寫到一個暫存檔,然後: - -``` -node scripts/issue-create.js --repo --title "<標題>" --body-file <暫存檔> \ - --labels "<標籤1,標籤2>" --dry-run -``` - -`--dry-run` 會印出將要送出的請求而不真的寫入。確認無誤後拿掉該旗標再跑一次。 - -同一段需求重跑不會產生第二顆議題:`issue-create` 以標題查重,發現同名議題就回傳既有那一顆 -並把 `created` 設為 `false`。 - -### 7. 補登規劃時間,然後起錶 - -議題有了,計時才有標的。**先補登、再起錶**,兩步指向同一顆議題。 - -先把「記下開始時間」到議題建立那一段補上去: - -``` -node scripts/time-log.js --repo --index <編號> --since <記下的開始時間> --dry-run -``` - -長度由腳本自己算,不必自己做減法,也不會讓兩邊的時鐘各算一次。終點看議題是不是這一輪 -建立的:是就補到**議題建立那一刻**,不是(對既有議題重跑)就補到**現在**——那一輪的 -規劃時間照樣要進報表。確認無誤後拿掉 `--dry-run` 再跑一次。 - -**不設時間上限,照實補登。** 中途去開會的那兩個小時會一起被算進去,這是刻意的:換來 -這個流程不必為此多長一題出來問使用者。時間記多了看得出來,記不到就永遠找不回來。 - -補登完才起錶: - -``` -node scripts/timer.js --repo --index <編號> --dry-run -``` - -一樣先試跑,確認無誤後拿掉 `--dry-run` 再跑一次。 - -順序不能反過來:錶一旦跑在這顆議題上,`time-log` 就會跳過不補——那一段已經有錶在記了, -再補一次會與錶涵蓋的區間重疊。**重跑是累計不是覆蓋**,每一輪各記一筆,報表上加總起來 -才是這顆議題真正花掉的規劃時間。 - -錶已經跑在別顆議題上時起錶會被擋下(`STOPWATCH_ON_OTHER_ISSUE`)。**照實告訴使用者 -是哪一顆,請他自己去停**,不要代勞:那一段時間該記在哪顆議題上只有他知道。順帶說明 -停錶不會動到任何既有的工作樹——碼錶只管時間、工作樹只管檔案。 - -### 8. 產生圖解版總覽 〔可委派〕 - -依 `references/artifact-contract.md` 從需求議題抽取結果組成 `schemaVersion: 1` JSON, -執行 `node scripts/overview-render.js` 產生自包含 HTML 與 manifest。HTML 只給使用者檢視, -不取代需求議題的詳細內容。若平台有 preview 能力就使用它;否則以短命 Node server -服務 `.tmp/` 檔案供瀏覽器截圖。只有真正可用的 preview URL 才執行既有 -`node scripts/issue-update.js --overview-url <網址>`。 -產出 HTML 可委派;預覽截圖、附件上傳與任何 `issue-update` 寫回都不委派,由主流程 -執行並處理錯誤。 - -規劃階段沒有工作包,因此 `workPackages` 為空陣列,不產生工作包依賴圖。 - -### 9. 停錶並回報 - -回報之前先停錶,這一段計時到此為止: - -``` -node scripts/timer.js --repo --index <編號> --stop -``` - -它**只停這一顆**上的錶。錶本來就沒在跑不算失敗(`碼錶已停` 會是 `false` 並附一句 -說明),回報照樣做完。 - -把議題編號與網址告訴使用者。不要把整份議題內容再貼一次 —— 連結點進去就看得到。 -## 流程圖的限制 - -流程圖的抽象節點與邊保存於 JSON,由 renderer 重新產生 SVG;議題若需要保存圖表, -由同一個 renderer 產生 Mermaid。節點數超過 12 或文字超過 8 字時拆圖或記錄 -`omitted` 原因,不由模型任意壓縮語意。 +流程圖只保留抽象節點與邊的文字描述;本流程不產生 HTML、SVG、manifest、截圖、附件或平台 preview。 ## 邊界 -- 不修改使用者的專案檔案。這個流程只讀輸入、寫 Gitea 議題。 -- 不建立標籤、不建立 Milestone、不建立專案看板。 -- 不關閉或刪除任何既有議題。 -- 規劃階段本身已含問題釐清,因此寫入 Gitea 前不再設額外的確認點;`--dry-run` 就是那道關卡。 -- 計時只動這顆需求議題:在它上面起錶、在它上面停錶、把規劃時間補登在它上面。 - **不停別顆議題上的錶**,被別顆的錶擋下時交還給使用者決定,不繞過去。 - -## Artifact 產物規則 - -圖解總覽不再套用 HTML 模板或依賴 Mermaid CDN。若此階段需要產生預覽, -先依 `references/artifact-contract.md` 組成需求級 JSON,再執行 -`node scripts/overview-render.js`。HTML、SVG 與 manifest 只寫入 `.tmp/`; -只有真正可用的 preview URL 才能使用既有 `--overview-url` 回寫。規劃階段沒有 -工作包時,`workPackages` 必須是空陣列,工作包全景不產生。 +- 不修改使用者專案檔案。 +- 不建立標籤、Milestone 或專案看板。 +- 不關閉或刪除既有議題。 +- 不產生任何預覽或 artifact。 +- 回報議題編號與網址,不重貼全文。 diff --git a/prompts/sdlc-report.md b/prompts/sdlc-report.md index e2a9fca..5b54770 100644 --- a/prompts/sdlc-report.md +++ b/prompts/sdlc-report.md @@ -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 [--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。 diff --git a/references/artifact-contract.md b/references/artifact-contract.md deleted file mode 100644 index 77f7f75..0000000 --- a/references/artifact-contract.md +++ /dev/null @@ -1,55 +0,0 @@ -# Artifact 產物契約 - -這份規則是需求級 HTML、截圖與議題附件的唯一正本。 - -## 資料流 - -1. `/sdlc-analyze` 完成排程後重新取得需求與工作包抽取 JSON。 -2. 流程組成一個 `schemaVersion: 1` 的需求根物件,暫存於 `.tmp/`。 -3. `scripts/overview-render.js --input --output --manifest ` 驗證並產生自包含 HTML。 -4. 預覽平台優先;沒有預覽能力時,使用可用 ephemeral port 短暫啟動 Node 靜態伺服器。 -5. 瀏覽器產生 full-page 與圖表局部 PNG,寫入 manifest。 -6. `scripts/issue-assets.js --repo ... --index ... --manifest ...` 上傳附件並就地更新需求議題附件索引。 - -JSON、HTML、SVG 與 PNG 全部寫入 `.tmp/`,不寫入目標專案。 - -## 根物件 - -必要欄位:`schemaVersion`、`source`、`requirement`、`workPackages`、`overview`。 - -`schemaVersion` 不是目前 renderer 支援的版本時必須停止;未知欄位保留。 - -## 圖表 - -圖表以抽象資料保存:`kind`、`direction`、`nodes`、`edges`。議題輸出由 renderer 產生 Mermaid;HTML 重新產生 SVG 或 HTML/CSS,不得直接搬用 Mermaid。 - -流程、依賴、架構與網路圖使用 SVG;卡片與清單使用 HTML/CSS。布局必須固定且可重跑。 - -## 公式 - -公式是可選欄位,保存 LaTeX 原文、標題、說明與用途。議題使用 `formula` fenced block;HTML 使用自包含 SVG。單一公式解析失敗時保留原文並標記未渲染,不得靜默遺失;公式欄位結構錯誤仍使整體 schema 驗證失敗。 - -## 截圖與附件 - -manifest 的每個 screenshot 必須包含 `role`、`path`、`fileName`、`sha256`。PNG 使用固定語意檔名,內容 hash 用 SHA-256。 - -full-page 與局部圖全部上傳為需求議題附件,不直接嵌入圖片。正文只保留一行附件索引;重跑時內容相同就重用,無法確認 hash 就新增附件。附件上傳或議題索引更新失敗是硬錯誤。 - -若平台提供真正可用的 preview URL,才使用既有 `--overview-url` 回寫;不可把 `.tmp/` 或 `0.0.0.0` 寫回議題。 - -## 截圖 backend - -平台 preview 是首選。沒有平台 preview 時,可執行: - -``` -node scripts/overview-capture.js --backend auto --url --output -``` - -若沒有瀏覽器但有 SVG rasterizer: - -``` -node scripts/overview-capture.js --backend svg --svg --output -``` - -`auto` 優先使用 Firefox,再使用 `rsvg-convert` 或 `resvg`。找不到任何 backend -必須失敗並保留 `.tmp/` 的 HTML/SVG,不得假裝已完成截圖。 diff --git a/scripts/claim.js b/scripts/claim.js index 9bc6632..ff36553 100644 --- a/scripts/claim.js +++ b/scripts/claim.js @@ -2,17 +2,9 @@ /** * 領取一顆工作包:上鎖、貼標籤。 * - * 鎖用 assignee 加標籤,不用碼錶——Gitea 只讓人讀自己的碼錶(`/user/stopwatches`), - * 看不到別人的錶,拿它當鎖會漏判。碼錶在這裡只有一個用途:發現自己忘了停掉上一顆。 + * 鎖用 assignee 加標籤,不用碼錶;碼錶與耗時統計已移除。 * - * **錶不在這一步起**。它等工作樹建好之後才由 timer.js 起動(見 branch-prep.js): - * 工作樹建立失敗會中止整個領取,錶要是先起了,使用者就被計了一段什麼都沒做的時間。 - * - * 四種狀態的處置: - * - 他人已認領 → 擋。不會兩個人做同一件事。 - * - 自己的錶跑在本議題 → 擋。這顆你已經在做了,別重複起錶。 - * - 自己的錶跑在別的議題 → 擋。先去停掉那一顆,否則工時會記錯地方。 - * - 沒有鎖(含自己已認領沒錶)→ 放行。後者正是中斷後重跑的情形。 + * 他人已認領時擋下,自己已認領時可冪等重跑;所有判斷都在寫入前完成。 * * 所有會擋的判斷都做在任何寫入之前:擋下來卻已經改了一半,比直接放行更難收拾。 * `--dry-run` 走的是同一條路,只是停在寫入之前——它印出的是這一顆此刻真正缺的那幾步, @@ -27,15 +19,12 @@ import { fetchIssue, giteaRequest, listLabels, - listStopwatches, main, parseFlags, parseIndex, parseRepo, preflight, resolveLogin, - stopwatchElsewhere, - stopwatchOnIssue, } from './lib.js'; /** 領取鎖的另一半。本 plugin 不自動建立標籤,這個名字要在 repo 上先存在。 */ @@ -62,11 +51,7 @@ main(async () => { const assignees = (issue.assignees ?? []).map((user) => user.login); const labels = (issue.labels ?? []).map((label) => label.name); - // 所有會擋的判斷都做完才輪到寫入,試跑與實跑走同一條路—— - // 試跑印得出漂亮的計畫、實跑卻被擋下來,那種落差最難查 checkClaimable(assignees, me, index); - await checkNoStopwatch(login, repo, index); - // 本 plugin 不建標籤,缺了就整件事不做,不要只設一半的鎖 const inProgress = (await listLabels(login, repo)).find((label) => label.name === IN_PROGRESS); if (!inProgress) { @@ -105,8 +90,6 @@ main(async () => { url: issue.html_url, assignee: me, labels, - // 鎖上好了,錶還沒起:它等工作樹建好之後才由 timer.js 起動 - 碼錶中: false, 已認領過, }; }); @@ -124,25 +107,3 @@ function checkClaimable(assignees, me, index) { } } -/** - * 自己的錶跑在任何議題上都擋,需要手動停錶後再領。 - * - * 不代勞停錶:那一段時間該記在哪顆議題上只有人知道,腳本自作主張會把工時記錯地方。 - * 錯誤碼分兩種,因為使用者的下一步不同——跑在本議題是「你已經在做了」, - * 跑在別的議題是「你忘了停掉那一顆」。 - */ -async function checkNoStopwatch(login, repo, index) { - const watches = await listStopwatches(login); - if (watches.length === 0) return; - - if (stopwatchOnIssue(watches, repo, index)) { - throw new ScriptError( - 'STOPWATCH_ON_THIS_ISSUE', - `你的碼錶已經跑在議題 #${index} 上,這顆你正在做;` + - '若要重新計時,請先在 Gitea 上手動停錶再執行一次——' + - '停錶只停計時,不會動到你既有的工作樹', - ); - } - - throw stopwatchElsewhere(watches[0], '領取'); -} diff --git a/scripts/issue-assets.js b/scripts/issue-assets.js deleted file mode 100755 index 17a33da..0000000 --- a/scripts/issue-assets.js +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env node -/** - * Upload overview screenshots to a Gitea issue and update its attachment index. - * The manifest is produced by the preview step and contains local PNG paths and hashes. - */ -import { existsSync, readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { - ScriptError, - expectOk, - fetchIssue, - giteaRequest, - giteaUpload, - main, - parseFlags, - parseIndex, - parseRepo, - preflight, - resolveLogin, -} from './lib.js'; -import { upsertLineInSection } from './issue-body.js'; - -const INDEX_LINE = '圖解版總覽附件:'; - -main(async () => { - const flags = parseFlags(process.argv.slice(2), { - required: ['repo', 'index', 'manifest'], - optional: ['host'], - booleans: ['dry-run'], - }); - const repo = parseRepo(flags.repo); - const index = parseIndex(flags.index); - const manifest = JSON.parse(readFileSync(resolve(flags.manifest), 'utf8')); - const screenshots = manifest.screenshots; - if (!Array.isArray(screenshots) || screenshots.length === 0) { - throw new ScriptError('MANIFEST_INVALID', 'manifest 必須包含至少一張 screenshots'); - } - for (const item of screenshots) { - if (!item.role || !item.path || !item.fileName || !item.sha256 || !existsSync(resolve(item.path))) { - throw new ScriptError('MANIFEST_INVALID', '每張截圖都必須有 role、path、fileName、sha256 且檔案存在'); - } - } - const login = resolveLogin({ host: flags.host }); - const issuePath = `/repos/${repo}/issues/${index}`; - const assetsPath = `${issuePath}/assets`; - if (flags['dry-run']) { - return { - dryRun: true, - repo, - index, - requests: [ - { method: 'GET', path: issuePath }, - { method: 'GET', path: assetsPath }, - ...screenshots.map((item) => ({ method: 'POST', path: assetsPath, file: item.path })), - ], - }; - } - await preflight(login, repo); - const issue = await fetchIssue(login, repo, index); - const assets = expectOk(await giteaRequest(login, 'GET', assetsPath), `GET ${assetsPath}`); - const existing = Array.isArray(assets) ? assets : []; - const indexed = parseIndexedAssets(issue.body ?? ''); - const uploaded = []; - for (const item of screenshots) { - const indexedAsset = indexed.find((asset) => asset.fileName === item.fileName && asset.sha256 === item.sha256); - const existingAsset = existing.find((asset) => asset.name === item.fileName && asset.sha256 === item.sha256); - const asset = indexedAsset ?? existingAsset; - if (asset) { - uploaded.push({ ...item, url: asset.url ?? asset.browser_download_url ?? asset.download_url, reused: true }); - continue; - } - const response = expectOk( - await giteaUpload(login, `${assetsPath}?name=${encodeURIComponent(item.fileName)}`, resolve(item.path), item.fileName), - `POST ${assetsPath}`, - ); - uploaded.push({ ...item, url: response.browser_download_url ?? response.download_url, reused: false }); - } - const body = updateIndex(issue.body ?? '', uploaded); - const update = body === issue.body ? null : expectOk(await giteaRequest(login, 'PATCH', issuePath, { body: { body } }), `PATCH ${issuePath}`); - return { repo, index, uploaded, updated: update !== null, url: update?.html_url ?? issue.html_url }; -}); - -function parseIndexedAssets(body) { - const line = body.split('\n').find((row) => row.startsWith(INDEX_LINE)) ?? ''; - return [...line.matchAll(/([^=;\s]+)=([^;\s]+)\s+sha256=([a-f0-9]{64})\s+(\S+)/g)].map((match) => ({ - role: match[1], - fileName: match[2], - sha256: match[3], - url: match[4], - })); -} - -function updateIndex(body, assets) { - const lines = assets.map((asset) => `${asset.role}=${asset.fileName} sha256=${asset.sha256} ${asset.url ?? '(網址未回傳)'}`); - return upsertLineInSection(body, '總覽', `${INDEX_LINE}${lines.join(';')}`); -} diff --git a/scripts/lib.js b/scripts/lib.js index 1ab3a89..3cf593b 100644 --- a/scripts/lib.js +++ b/scripts/lib.js @@ -30,7 +30,7 @@ import { fileURLToPath } from 'node:url'; /** 帶錯誤碼的失敗。呼叫端靠 code 分辨是哪一步壞了,訊息則要能指出去哪裡改。 */ export class ScriptError extends Error { /** - * @param {string} code 可區分的錯誤碼,例如 TIME_TRACKER_OFF + * @param {string} code 可區分的錯誤碼,例如 REPORT_UNAVAILABLE * @param {string} message 給人看的訊息,必要時附上「該改哪裡」 */ constructor(code, message) { @@ -776,7 +776,6 @@ export async function preflight(login, repo) { const user = await checkLogin(login); const info = await fetchRepo(login, repo); await checkIssueWrite(login, repo, info); - checkTimeTracker(info); return { repo: info, user }; } @@ -896,15 +895,6 @@ async function checkIssueWrite(login, repo, info) { } } -/** 第四層:repo 是否已開啟時間追蹤 */ -function checkTimeTracker(info) { - if (info.internal_tracker?.enable_time_tracker !== true) { - throw new ScriptError( - 'TIME_TRACKER_OFF', - 'repo 尚未開啟時間追蹤,工時碼錶無法運作;請到 Settings → Advanced Settings → Enable Time Tracker 開啟', - ); - } -} /** * 讀一個由 flag 指定的文字檔。 @@ -1013,87 +1003,6 @@ export async function mergedByMe(login, repo, id, me) { return reactions.some((reaction) => reaction.content === '+1' && reaction.user?.login === me); } -// ── 碼錶 ─────────────────────────────────────────────────────────── - -/** - * 目前跑在自己身上的碼錶。 - * - * Gitea 只讓人讀自己的碼錶,看不到別人的——所以這份清單的語意永遠是「**我**的錶」, - * 它用來發現自己忘了停上一顆,不是用來判斷別人有沒有在做(那看 assignee)。 - * @param {{base: string, token: string}} login - * @returns {Promise} - */ -export async function listStopwatches(login) { - const path = '/user/stopwatches'; - return expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? []; -} - -/** - * 「你的錶正跑在別顆議題上」的擋路錯誤。領取與起錶都會撞到它,訊息只寫一份。 - * - * 一定要明說停錶不會動到工作樹:使用者常以為停錶等於放棄那顆工作包,於是寧可不停, - * 而工時就記到別顆議題去了。碼錶只管時間,工作樹只管檔案,兩者互不相干。 - * @param {object} watch listStopwatches 裡的一顆錶 - * @param {string} 動作 擋在哪件事之前,例如「領取」「起錶」 - */ -export function stopwatchElsewhere(watch, 動作) { - return new ScriptError( - 'STOPWATCH_ON_OTHER_ISSUE', - `你的碼錶正跑在 ${watch.repo_owner_name}/${watch.repo_name} 的議題 ` + - `#${watch.issue_index} 上,${動作}前請先手動停錶,否則工時會記到那一顆去;` + - '停錶只停計時,不會動到任何既有的工作樹', - ); -} - -/** - * 這些碼錶裡,跑在指定議題上的那一顆。 - * 比對要連 repo 一起看:不同 repo 的同號議題是兩件事。 - * @param {object[]} watches listStopwatches 的結果 - * @param {string} repo owner/name - * @param {number} index - * @returns {object|null} - */ -export function stopwatchOnIssue(watches, repo, index) { - return ( - watches.find( - (watch) => `${watch.repo_owner_name}/${watch.repo_name}` === repo && watch.issue_index === index, - ) ?? null - ); -} - -/** - * 「這顆議題上沒有碼錶在跑」的回法不只一種:看過 500,也看過 409。 - * 狀態碼隨站台版本而異,所以認的是「狀態碼在這一組裡 **且** 訊息說的是碼錶」—— - * 只看訊息會把真的伺服器錯誤一起吞掉,只看狀態碼會把別的衝突也當成沒錶。 - */ -const NO_STOPWATCH_STATUS = [409, 500]; - -/** - * 停錶。停在指定議題上,只停那一顆——端點本身就是議題範圍的, - * 停錶不會波及別顆議題上的錶,那正是領取鎖那條規則要守住的事。 - * - * 錶沒在跑不算失敗:停錶多半排在別的事情做完之後(開完 PR、回報完), - * 把「本來就沒在跑」報成失敗,只會讓人以為前面那件事沒做成而重跑一次。 - * - * @param {{base: string, token: string}} login - * @param {string} repo owner/name - * @param {number} index - * @returns {Promise} 這次真的停了一支錶才是 true - */ -export async function stopStopwatch(login, repo, index) { - const path = `/repos/${repo}/issues/${index}/stopwatch/stop`; - const response = await giteaRequest(login, 'POST', path, { body: {} }); - if (response.status >= 200 && response.status < 300) return true; - - if ( - NO_STOPWATCH_STATUS.includes(response.status) && - /stopwatch/i.test(response.body?.message ?? '') - ) { - return false; - } - expectOk(response, `POST ${path}`); - return false; -} // ── 標籤 ─────────────────────────────────────────────────────────── diff --git a/scripts/overview-capture.js b/scripts/overview-capture.js deleted file mode 100755 index 7ccbba5..0000000 --- a/scripts/overview-capture.js +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env node -/** - * Capture an overview using an available non-managed backend. - * Managed browser preview remains the preferred platform-level path; this CLI handles - * Firefox and deterministic SVG rasterization when those binaries are installed. - */ -import { execFileSync, spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { ScriptError, main, parseFlags } from './lib.js'; - -main(async () => { - const flags = parseFlags(process.argv.slice(2), { - required: ['output'], - optional: ['url', 'svg', 'backend', 'width', 'height'], - }); - const output = resolve(flags.output); - mkdirSync(dirname(output), { recursive: true }); - const backend = chooseBackend(flags.backend, flags.url, flags.svg); - if (backend === 'firefox') captureFirefox(flags.url, output, flags.width, flags.height); - else captureSvg(flags.svg, output, flags.width, flags.height); - return { backend, output }; -}); - -function chooseBackend(requested, url, svg) { - if (requested && requested !== 'auto' && requested !== 'firefox' && requested !== 'svg') { - throw new ScriptError('BACKEND_UNKNOWN', `不支援的 capture backend:${requested}`); - } - if (requested === 'firefox') { - requireBinary('firefox'); - if (!url) throw new ScriptError('URL_REQUIRED', 'Firefox backend 需要 --url'); - return 'firefox'; - } - if (requested === 'svg') { - requireSvgInput(svg); - requireAnyBinary(['rsvg-convert', 'resvg']); - return 'svg'; - } - if (url && hasBinary('firefox')) return 'firefox'; - if (svg && (hasBinary('rsvg-convert') || hasBinary('resvg'))) return 'svg'; - throw new ScriptError('NO_CAPTURE_BACKEND', '沒有可用的 Firefox 或 SVG rasterizer;請使用平台 preview,或安裝 firefox、rsvg-convert、resvg'); -} - -function captureFirefox(url, output, width, height) { - const args = ['--headless']; - if (width) args.push('--window-size', `${width}${height ? `,${height}` : ''}`); - args.push('--screenshot', output, url); - const result = spawnSync('firefox', args, { encoding: 'utf8' }); - if (result.status !== 0 || !existsSync(output)) throw new ScriptError('CAPTURE_FAILED', `Firefox 截圖失敗:${result.stderr || result.stdout || '沒有輸出檔案'}`); -} - -function captureSvg(svg, output, width, height) { - requireSvgInput(svg); - if (hasBinary('rsvg-convert')) { - const args = [`--output=${output}`]; - if (width) args.push(`--width=${width}`); - if (height) args.push(`--height=${height}`); - args.push(resolve(svg)); - const result = spawnSync('rsvg-convert', args, { encoding: 'utf8' }); - if (result.status !== 0) throw new ScriptError('CAPTURE_FAILED', `rsvg-convert 失敗:${result.stderr || result.stdout}`); - } else { - const args = []; - if (width) args.push('-w', String(width)); - args.push(resolve(svg), output); - const result = spawnSync('resvg', args, { encoding: 'utf8' }); - if (result.status !== 0) throw new ScriptError('CAPTURE_FAILED', `resvg 失敗:${result.stderr || result.stdout}`); - } - if (!existsSync(output)) throw new ScriptError('CAPTURE_FAILED', 'rasterizer 沒有產生輸出檔案'); -} - -function requireSvgInput(svg) { - if (!svg || !existsSync(resolve(svg))) throw new ScriptError('SVG_REQUIRED', 'SVG backend 需要存在的 --svg'); -} -function requireBinary(binary) { if (!hasBinary(binary)) throw new ScriptError('BINARY_NOT_FOUND', `找不到 ${binary}`); } -function requireAnyBinary(binaries) { if (!binaries.some(hasBinary)) throw new ScriptError('BINARY_NOT_FOUND', `找不到 ${binaries.join(' 或 ')}`); } -function hasBinary(binary) { try { execFileSync('sh', ['-c', `command -v ${binary}`], { stdio: 'ignore' }); return true; } catch { return false; } } diff --git a/scripts/overview-render.js b/scripts/overview-render.js deleted file mode 100755 index f365c06..0000000 --- a/scripts/overview-render.js +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env node -/** - * Validate an overview artifact JSON document and render a self-contained HTML file. - * The renderer never calls Gitea; callers provide a JSON snapshot from the extractors. - */ -import { createHash } from 'node:crypto'; -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { ScriptError, main, parseFlags } from './lib.js'; - -const SCHEMA_VERSION = 1; -const WORK_TYPES = new Set(['implementation', 'data', 'architecture', 'schedule', 'documentation']); -const DIAGRAM_KINDS = new Set(['flowchart', 'dependency', 'architecture', 'network', 'repo', 'schedule']); - -export function validateOverview(document) { - if (!document || typeof document !== 'object' || Array.isArray(document)) fail('SCHEMA_INVALID', '根資料必須是物件'); - if (document.schemaVersion !== SCHEMA_VERSION) fail('SCHEMA_UNSUPPORTED', `只支援 schemaVersion ${SCHEMA_VERSION}`); - if (!document.source?.requirement?.repo || !Number.isInteger(document.source.requirement.index)) { - fail('SCHEMA_INVALID', 'source.requirement 必須包含 repo 與整數 index'); - } - if (!document.requirement || typeof document.requirement !== 'object') fail('SCHEMA_INVALID', '缺少 requirement'); - if (!Array.isArray(document.workPackages)) fail('SCHEMA_INVALID', 'workPackages 必須是陣列'); - for (const [position, workPackage] of document.workPackages.entries()) validateWorkPackage(workPackage, position); - validateDiagrams(document.requirement.diagrams, 'requirement'); - return document; -} - -function validateWorkPackage(value, position) { - if (!value || typeof value !== 'object') fail('SCHEMA_INVALID', `workPackages[${position}] 必須是物件`); - for (const key of ['title', 'description', 'scope', 'issue']) { - if (typeof value[key] !== 'string' || value[key].trim() === '') fail('SCHEMA_INVALID', `workPackages[${position}].${key} 必填`); - } - if (!WORK_TYPES.has(value.type)) fail('SCHEMA_INVALID', `workPackages[${position}].type 不支援:${value.type}`); - for (const key of ['repos', 'depends', 'todos', 'acceptance']) { - if (!Array.isArray(value[key])) fail('SCHEMA_INVALID', `workPackages[${position}].${key} 必須是陣列`); - } - if (value.interfaces !== undefined) { - if (!Array.isArray(value.interfaces)) fail('SCHEMA_INVALID', `workPackages[${position}].interfaces 必須是陣列`); - for (const contract of value.interfaces) validateInterface(contract, position); - } - validateDiagrams(value.diagrams, `workPackages[${position}]`); - if (value.formulas !== undefined && !Array.isArray(value.formulas)) fail('SCHEMA_INVALID', `workPackages[${position}].formulas 必須是陣列`); -} - -function validateInterface(contract, position) { - const kinds = { - http: ['method', 'path', 'request', 'response', 'errors', 'example'], - cli: ['command', 'args', 'stdout', 'stderr', 'exitCodes', 'example'], - function: ['signature', 'input', 'output', 'errors', 'example'], - event: ['topic', 'payload', 'producer', 'consumer', 'delivery', 'errors', 'example'], - storage: ['operation', 'entity', 'schema', 'constraints', 'transaction', 'example'], - }; - if (!kinds[contract?.interfaceType]) fail('SCHEMA_INVALID', `workPackages[${position}] 有未知 interfaceType`); - for (const key of kinds[contract.interfaceType]) { - if (contract[key] === undefined) fail('SCHEMA_INVALID', `介面契約缺少 ${contract.interfaceType}.${key}`); - } -} - -function validateDiagrams(diagrams, owner) { - if (diagrams === undefined) return; - if (!Array.isArray(diagrams)) fail('SCHEMA_INVALID', `${owner}.diagrams 必須是陣列`); - for (const diagram of diagrams) { - if (!DIAGRAM_KINDS.has(diagram?.kind)) fail('DIAGRAM_UNSUPPORTED', `${owner} 有未知圖表類型`); - if (!Array.isArray(diagram.nodes) || !Array.isArray(diagram.edges)) fail('SCHEMA_INVALID', `${owner} 圖表必須有 nodes 與 edges`); - const ids = new Set(diagram.nodes.map((node) => node.id)); - for (const edge of diagram.edges) if (!ids.has(edge.from) || !ids.has(edge.to)) fail('SCHEMA_INVALID', `${owner} 圖表有不存在的邊端點`); - } -} - -export function renderOverview(document) { - validateOverview(document); - const requirement = document.requirement; - const packages = document.workPackages; - const diagrams = [...(requirement.diagrams ?? []), ...packages.flatMap((workPackage) => workPackage.diagrams ?? [])]; - const html = `${escapeHtml(requirement.title ?? '需求總覽')}

${escapeHtml(requirement.title ?? '需求總覽')}

來源:${escapeHtml(document.source.requirement.repo)}#${document.source.requirement.index}

${escapeHtml(requirement.summary ?? '')}

${renderListSection('目標', requirement.goals, 'goals')}${renderListSection('非目標', requirement.nonGoals, 'non-goals')}${renderDiagrams(diagrams)}

工作包

${packages.map(renderWorkPackage).join('')}
${renderFormulas(requirement.formulas)}
schemaVersion ${document.schemaVersion} · 產生時間 ${escapeHtml(document.overview?.generatedAt ?? '')}
`; - return html; -} -export function renderFallbackSvg(document) { - validateOverview(document); - const diagrams = [...(document.requirement.diagrams ?? []), ...document.workPackages.flatMap((workPackage) => workPackage.diagrams ?? [])]; - const height = 180 + diagrams.length * 300; - const diagramMarkup = diagrams.map((diagram, index) => `${renderSvgContents(diagram)}`).join(''); - return `${escapeHtml(document.requirement.title ?? '需求總覽')}${escapeHtml(document.requirement.summary ?? '')}${diagramMarkup}`; -} - -function renderWorkPackage(workPackage, index) { - const slug = slugify(workPackage.title) || `work-package-${index + 1}`; - const done = workPackage.todos.filter((todo) => todo.done).length; - return `

${escapeHtml(workPackage.title)}

${escapeHtml(workPackage.description)}

類型
${escapeHtml(workPackage.type)}
進度
${done}/${workPackage.todos.length} 項待辦
依賴
${escapeHtml(workPackage.depends.join(', ') || '無')}
repo
${escapeHtml(workPackage.repos.join(', ') || '無')}

查看工作包詳細內容

`; -} - -function renderDiagrams(diagrams) { - return diagrams.map((diagram, index) => `

${escapeHtml(diagram.title ?? diagram.kind)}

${renderSvg(diagram)}
${diagram.omitted ? `

未產生:${escapeHtml(diagram.omitted)}

` : ''}
`).join(''); -} - -function renderSvg(diagram) { - const columns = Math.max(1, Math.ceil(Math.sqrt(diagram.nodes.length || 1))); - const width = Math.max(640, columns * 220); - const height = Math.max(180, Math.ceil((diagram.nodes.length || 1) / columns) * 100 + 80); - const positions = new Map(diagram.nodes.map((node, index) => [node.id, { x: 30 + (index % columns) * 210, y: 35 + Math.floor(index / columns) * 100 }])); - const edges = diagram.edges.map((edge) => { const from = positions.get(edge.from); const to = positions.get(edge.to); return from && to ? `${escapeHtml(edge.label ?? '')}` : ''; }).join(''); - const nodes = diagram.nodes.map((node) => { const point = positions.get(node.id); return `${escapeHtml(node.label ?? node.id)}`; }).join(''); - return `${edges}${nodes}`; -} - -function renderSvgContents(diagram) { - return renderSvg(diagram).replace(/^]*>/, '').replace(/<\/svg>$/, ''); -} -function renderListSection(title, values, id) { if (!Array.isArray(values) || values.length === 0) return ''; return `

${title}

    ${values.map((value) => `
  • ${escapeHtml(typeof value === 'string' ? value : value.text ?? '')}
  • `).join('')}
`; } - -function renderFormulas(formulas) { - if (!Array.isArray(formulas) || formulas.length === 0) return ''; - return `

公式

${formulas.map((formula) => { - const latex = String(formula.latex ?? ''); - const supported = /^[A-Za-z0-9\s+\-*/=().,_^{}]+$/.test(latex); - return `

${escapeHtml(formula.title ?? '公式')}

${escapeHtml(formula.description ?? '')}

${supported ? `${escapeHtml(latex)}` : `

公式未渲染,原文如下:

${escapeHtml(latex)}
`}
`; - }).join('')}
`; -} -function styles() { return `:root{color-scheme:light dark;--bg:#fff;--fg:#1f2328;--muted:#59636e;--line:#d1d9e0;--surface:#f6f8fa;--accent:#0969da}*{box-sizing:border-box}body{margin:0;padding:48px 16px 96px;background:var(--bg);color:var(--fg);font:16px/1.7 -apple-system,"Noto Sans TC","Microsoft JhengHei",sans-serif}main{max-width:980px;margin:0 auto}header{border-bottom:1px solid var(--line);padding-bottom:24px;margin-bottom:40px}h1{font-size:30px;line-height:1.3}h2{font-size:15px;letter-spacing:.08em;color:var(--muted);margin:32px 0 16px}h3{line-height:1.4}.meta,.omitted{color:var(--muted)}.lede{font-size:21px;padding:20px 24px;background:var(--surface);border-left:3px solid var(--accent)}ul{padding-left:24px}.diagram{padding:20px;background:var(--surface);border:1px solid var(--line);border-radius:8px;overflow:auto}.diagram svg{display:block;min-width:620px;height:auto}.diagram rect{fill:var(--bg);stroke:var(--accent);stroke-width:2}.diagram text{fill:var(--fg);font-size:14px;text-anchor:middle}.diagram line{stroke:var(--accent);stroke-width:2}.diagram marker path{fill:var(--accent)}article{border-top:1px solid var(--line);padding:16px 0}dl{display:grid;grid-template-columns:max-content 1fr;gap:4px 16px;color:var(--muted)}dt{font-weight:600}dd{margin:0}a{color:var(--accent)}pre{white-space:pre-wrap;background:var(--surface);padding:12px;border-radius:6px}footer{margin-top:56px;padding-top:20px;border-top:1px solid var(--line);color:var(--muted);font-size:13px}@media (prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--muted:#9198a1;--line:#3d444d;--surface:#151b23;--accent:#4493f8}}`; } -function slugify(value) { return String(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } -function escapeHtml(value) { return String(value ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); } -const escapeAttr = escapeHtml; -function fail(code, message) { throw new ScriptError(code, message); } - -if (process.argv[1] === new URL(import.meta.url).pathname) { - main(async () => { - const flags = parseFlags(process.argv.slice(2), { required: ['input', 'output'], optional: ['manifest', 'svg'] }); - const inputPath = resolve(flags.input); - const outputPath = resolve(flags.output); - const document = JSON.parse(readFileSync(inputPath, 'utf8')); - validateOverview(document); - mkdirSync(dirname(outputPath), { recursive: true }); - writeFileSync(outputPath, renderOverview(document)); - const result = { schemaVersion: SCHEMA_VERSION, html: outputPath, sha256: createHash('sha256').update(readFileSync(outputPath)).digest('hex') }; - if (flags.svg) { - const svgPath = resolve(flags.svg); - mkdirSync(dirname(svgPath), { recursive: true }); - writeFileSync(svgPath, renderFallbackSvg(document)); - result.svg = svgPath; - } - if (flags.manifest) { mkdirSync(dirname(resolve(flags.manifest)), { recursive: true }); writeFileSync(resolve(flags.manifest), `${JSON.stringify({ ...result, screenshots: [] }, null, 2)}\n`); } - return result; - }); -} diff --git a/scripts/pr-create.js b/scripts/pr-create.js old mode 100644 new mode 100755 index 1b86fd5..f09013a --- a/scripts/pr-create.js +++ b/scripts/pr-create.js @@ -1,80 +1,8 @@ #!/usr/bin/env node -/** - * 開立 PR,然後停錶。 - * - * 標題等同分支名:reviewer 在列表上看到的就是分支,兩者對不上會找錯 PR。 - * - * 描述的段落固定且順序固定——reviewer 每次都在同一個位置找到要找的資訊。缺一段或順序 - * 不對就擋下,不自動補:補出來的段落是編的,而 reviewer 會把它當成真的。 - * - * 「測試結果」另外驗一次它不是空話。那一段是 reviewer 唯一能判斷「這東西真的跑過嗎」 - * 的依據,寫「已測試通過」等於沒寫。沒有自動化測試時,寫可重現的手動驗證步驟也算數。 - * - * 停錶排在 PR 開出去之後,而且只在 PR 真的建立了才停:工時要記在真的有做事的那段 - * 時間上。錶本來就沒在跑不算失敗——PR 已經開出去了,不該把整件事報成失敗。 - * - * **錶停在議題所在的 repo,不是 PR 所在的 repo。** 工作包議題與目標專案常常不是同一個 - * repo(議題在需求的 repo,程式碼在 `repos` 列的那些),拿 PR 的 repo 去停錶,停到的是 - * 別人的議題,而自己的錶還在跑。預設兩者相同,不同時用 `--issue-repo` 指出來。 - * - * 重跑不會開出第二顆 PR:先查同一個 head 有沒有開著的 PR,有就回傳它並把 `created` - * 設為 `false`,然後照樣停錶——那一步可能正是上次中斷的地方。 - * - * 用法: - * node scripts/pr-create.js --repo owner/name --head <分支> --base <分支> - * --body-file <描述檔> --index 13 - * [--issue-repo owner/name] [--host <網址>] [--dry-run] - */ -import { - ScriptError, - expectOk, - giteaRequest, - main, - parseFlags, - parseIndex, - parseRepo, - preflight, - readTextFile, - resolveLogin, - stopStopwatch, -} from './lib.js'; +import { ScriptError, expectOk, giteaRequest, main, parseFlags, parseIndex, parseRepo, preflight, readTextFile, resolveLogin } from './lib.js'; -/** 描述的固定段落,順序即 reviewer 閱讀的順序 */ -const SECTIONS = [ - '摘要', - '需求議題', - '工作包議題', - '變更內容', - '設計重點', - '解決的問題', - '影響的功能', - '測試結果', -]; - -/** - * 「測試結果」裡等於沒寫的那幾句。 - * 不是窮舉,是擋住最常見的偷懶寫法——真的跑過的話,貼輸出比打這幾個字還快。 - */ -const EMPTY_TALK = new Set([ - '無', - '沒有', - 'N/A', - 'n/a', - '已測試', - '已測試通過', - '測試通過', - '測試皆通過', - '測試皆已通過', - '全部通過', - '全數通過', - '皆通過', - '無異常', - '沒有問題', - '一切正常', - '正常', - 'ok', - 'OK', -]); +const SECTIONS = ['摘要', '需求議題', '工作包議題', '變更內容', '設計重點', '解決的問題', '影響的功能', '測試結果']; +const EMPTY_TALK = new Set(['無', '沒有', 'N/A', 'n/a', '已測試', '已測試通過', '測試通過', '正常', 'ok', 'OK']); main(async () => { const flags = parseFlags(process.argv.slice(2), { @@ -83,132 +11,31 @@ main(async () => { booleans: ['dry-run'], }); const repo = parseRepo(flags.repo); - // 議題預設與 PR 同一個 repo;跨 repo 的工作包要用 --issue-repo 指出來 const issueRepo = parseRepo(flags['issue-repo'] ?? flags.repo); - const head = flags.head; - const base = flags.base; const index = parseIndex(flags.index); const body = readTextFile(flags['body-file'], '--body-file'); - - // 描述先驗完再談寫入:不合格的描述不該等到實跑才發現 checkSections(body); checkTestResult(body); - const pullsPath = `/repos/${repo}/pulls`; - const stopPath = `/repos/${issueRepo}/issues/${index}/stopwatch/stop`; - const payload = { title: head, head, base, body }; - - // 試跑也把登入解出來:沒跑過 tea login 的話,這一步就會說出來,不必等到實跑 + const payload = { title: flags.head, head: flags.head, base: flags.base, body }; const login = resolveLogin({ host: flags.host }); - - if (flags['dry-run']) { - return { - dryRun: true, - repo, - issueRepo, - index, - head, - base, - title: head, - requests: [ - { method: 'POST', path: pullsPath, body: payload }, - { method: 'POST', path: stopPath, body: {} }, - ], - }; - } - + if (flags['dry-run']) return { dryRun: true, repo, issueRepo, index, head: flags.head, base: flags.base, title: flags.head, requests: [{ method: 'POST', path: pullsPath, body: payload }] }; await preflight(login, repo); - - // 冪等:同一個 head 已經有開著的 PR 就用它,重跑不會開出第二顆 - const existing = await findOpenPull(login, repo, head); - const pull = existing ?? expectOk( - await giteaRequest(login, 'POST', pullsPath, { body: payload }), - `POST ${pullsPath}`, - ); - - // 錶只在 PR 確實存在之後才停。既有的 PR 也要停——那一步可能正是上次中斷的地方。 - const stopped = await stopStopwatch(login, issueRepo, index); - - return { - repo, - issueRepo, - index, - created: existing === null, - title: pull.title, - url: pull.html_url, - number: pull.number, - head, - base, - 碼錶已停: stopped, - ...(stopped ? {} : { note: '碼錶本來就沒在這顆議題上運轉,PR 已經在了,這一步略過。' }), - }; + const existing = await findOpenPull(login, repo, flags.head); + const pull = existing ?? expectOk(await giteaRequest(login, 'POST', pullsPath, { body: payload }), `POST ${pullsPath}`); + return { repo, issueRepo, index, created: existing === null, title: pull.title, url: pull.html_url, number: pull.number, head: flags.head, base: flags.base }; }); - -/** - * 找同一個 head 上開著的 PR。 - * 重跑時 Gitea 會對重複的 PR 回 422,而那個錯誤看不出「其實已經開好了」—— - * 先查一次,重跑就是安靜地接上。 - */ +function checkSections(body) { + const headings = [...body.matchAll(/^## (.+)$/gm)].map((match) => match[1].trim()); + if (headings.length !== SECTIONS.length || headings.some((heading, i) => heading !== SECTIONS[i])) throw new ScriptError('BAD_PR_BODY', `PR 描述必須依序包含:${SECTIONS.join('、')}`); +} +function checkTestResult(body) { + const match = body.match(/^## 測試結果\s*\n([\s\S]*?)(?=^## |$)/m); + if (!match || EMPTY_TALK.has(match[1].trim())) throw new ScriptError('BAD_PR_TEST_RESULT', '測試結果必須填入實際執行的命令與輸出'); +} async function findOpenPull(login, repo, head) { const path = `/repos/${repo}/pulls`; - const pulls = expectOk( - await giteaRequest(login, 'GET', path, { query: { state: 'open' } }), - `GET ${path}`, - ) ?? []; - - return pulls.find((pull) => pull.head?.ref === head) ?? null; -} - - - -/** 八個段落一個都不能少,而且順序要與 SECTIONS 一致 */ -function checkSections(body) { - const found = [...body.matchAll(/^##\s+(.+?)\s*$/gm)].map((match) => match[1]); - - const missing = SECTIONS.filter((section) => !found.includes(section)); - if (missing.length > 0) { - throw new ScriptError( - 'MISSING_SECTION', - `PR 描述缺少這幾段:${missing.join('、')};` + - `固定的段落順序為 ${SECTIONS.join('/')},reviewer 每次都在同一個位置找同一件事`, - ); - } - - const order = found.filter((section) => SECTIONS.includes(section)); - if (order.join('\n') !== SECTIONS.join('\n')) { - throw new ScriptError( - 'SECTION_ORDER', - `PR 描述的段落順序不對:收到的是 ${order.join('/')},應為 ${SECTIONS.join('/')}`, - ); - } -} - -/** - * 「測試結果」不能是空話。 - * 判斷很窄——整段的每一行都是已知的偷懶寫法才算。窄是刻意的: - * 這一關要擋的是明顯沒跑過就交差,不是去評價別人的測試寫得夠不夠好, - * 所以只要混進了一行真的輸出就放行。 - */ -function checkTestResult(body) { - const lines = body.split('\n'); - // 找行首的那個標題,而不是 indexOf:描述裡引用到「## 測試結果」這幾個字是常有的事 - const start = lines.findIndex((line) => /^##\s+測試結果\s*$/.test(line)); - const rest = lines.slice(start + 1); - const end = rest.findIndex((line) => /^##\s+/.test(line)); - const content = (end === -1 ? rest : rest.slice(0, end)).join('\n').trim(); - - const written = content.split('\n').filter((line) => line.trim() !== ''); - // 每一行都是空話才算空話:混了實際輸出就放行,這一關不評價測試寫得好不好 - const allEmptyTalk = - written.length > 0 && - written.every((line) => EMPTY_TALK.has(line.trim().replace(/[。..]$/, ''))); - - if (content === '' || allEmptyTalk) { - throw new ScriptError( - 'EMPTY_TEST_RESULT', - '「測試結果」要放實際跑過的輸出;沒有自動化測試時,寫出 reviewer 自己能重現的' + - '手動驗證步驟。「已測試通過」這種寫法看不出跑過什麼,等於沒寫', - ); - } + const pulls = expectOk(await giteaRequest(login, 'GET', path, { query: { state: 'open' } }), `GET ${path}`) ?? []; + return pulls.find((pull) => pull.head?.label === head || pull.head?.ref === head) ?? null; } diff --git a/scripts/report.js b/scripts/report.js old mode 100644 new mode 100755 index 5c4556b..7a5f210 --- a/scripts/report.js +++ b/scripts/report.js @@ -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)}`; -} diff --git a/scripts/time-log.js b/scripts/time-log.js deleted file mode 100644 index 1be2452..0000000 --- a/scripts/time-log.js +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env node -/** - * 補登一段沒有錶記到的工時。 - * - * 規劃階段最耗時的那一段——讀齊輸入、逐項詢問、組出議題內容——發生在議題建立**之前**, - * 那時候沒有標的可起錶(議題還不存在)。這段時間只能事後補登,否則報表上的規劃永遠是零, - * 久了會讓人以為規劃不花時間,而那正是估算失準最常見的來源。 - * - * **長度由這支腳本自己算**:終點減掉 `--since`。交給 agent 做減法,等於讓兩邊的時鐘與 - * 時區各算一次,而算錯了報表上看不出來。 - * - * 終點取哪一刻,看議題是不是這一輪建立的: - * - 議題建立於 `--since` 之後 → 終點是**議題的建立時間**。這是第一次跑,補的正是 - * 「指令開始到議題建立」那一段。 - * - 議題比 `--since` 還早 → 終點是**補登的當下**。這是對既有議題重跑,那一輪的規劃 - * 時間照樣要進報表;拿舊的建立時間當終點會算出負數,等於把這一輪的工夫丟掉。 - * - * **不設時間上限,照實補登。** 中途去開會的那兩個小時會一起被算進去——換來這個流程 - * 不必為此多長一題出來問使用者。時間記多了看得出來,記不到就永遠找不回來。 - * - * **重跑會累計,不會覆蓋**:每一輪各記一筆,報表上加總起來才是這顆議題真正花掉的規劃 - * 時間。唯一跳過的情形是自己的錶已經跑在這顆議題上——補登排在起錶之前,錶在跑就代表 - * 這一輪已經走到起錶那一步了,再補一次會與錶涵蓋的區間重疊。 - * - * 只寫工時,不動任何錶——別顆議題上有錶在跑也照補,那兩件事互不相干。 - * - * 用法: - * node scripts/time-log.js --repo owner/name --index 42 --since - * [--host <網址>] [--dry-run] - */ -import { - ScriptError, - expectOk, - fetchIssue, - giteaRequest, - listStopwatches, - main, - parseFlags, - parseIndex, - parseRepo, - preflight, - resolveLogin, - stopwatchOnIssue, -} from './lib.js'; - -main(async () => { - const flags = parseFlags(process.argv.slice(2), { - required: ['repo', 'index', 'since'], - optional: ['host'], - booleans: ['dry-run'], - }); - const repo = parseRepo(flags.repo); - const index = parseIndex(flags.index); - const since = parseSince(flags.since); - const dryRun = flags['dry-run'] === true; - const timesPath = `/repos/${repo}/issues/${index}/times`; - - const login = resolveLogin({ host: flags.host }); - // 試跑照樣讀現況:手寫一份固定的清單會跟實作走鐘,也說不出「錶已經在跑了」 - if (!dryRun) await preflight(login, repo); - const issue = await fetchIssue(login, repo, index); - - const 建立時間 = Date.parse(issue.created_at); - if (Number.isNaN(建立時間)) { - throw new ScriptError( - 'NO_CREATED_AT', - `${repo} 的議題 ${index} 沒有可解讀的建立時間,補登的終點判斷不出來`, - ); - } - - // 這一輪建立的議題就補到建立那一刻;既有的議題則補到現在,那一輪的工夫一樣要進報表 - const 這輪建立 = 建立時間 > since; - const 迄 = 這輪建立 ? 建立時間 : Date.now(); - const 秒數 = Math.round((迄 - since) / 1000); - - const 略過 = await skipReason(login, repo, index, 秒數); - const planned = 略過 === null ? [{ method: 'POST', path: timesPath, body: { time: 秒數 } }] : []; - const 報告 = { - repo, - index: issue.number, - title: issue.title, - url: issue.html_url, - since: new Date(since).toISOString(), - 迄: new Date(迄).toISOString(), - 依據: 這輪建立 ? '議題建立' : '補登當下', - 秒數, - 補登: 略過 === null, - ...(略過 ? { note: 略過 } : {}), - }; - - if (dryRun) { - return { dryRun: true, ...報告, requests: planned }; - } - - for (const { method, path, body } of planned) { - expectOk(await giteaRequest(login, method, path, { body }), `${method} ${path}`); - } - - return 報告; -}); - -/** - * 不該補的理由,沒有就回 null。 - * - * 只有兩種:長度非正的那一段根本不存在;錶已經跑在這顆議題上,代表這一輪已經走到起錶 - * 那一步,再補就與錶涵蓋的區間重疊。重跑本身不是理由——每一輪的規劃時間都要記上去。 - */ -async function skipReason(login, repo, index, 秒數) { - if (秒數 <= 0) { - return '指令開始時間不早於現在,沒有可補登的區間;兩邊時鐘差幾秒是常事,這不算失敗。'; - } - if (stopwatchOnIssue(await listStopwatches(login), repo, index)) { - return '碼錶已經跑在這顆議題上。補登排在起錶之前,錶在跑就代表這一輪補過了,補下去會與錶重疊。'; - } - return null; -} - -/** - * 解析 `--since`。擋在打 Gitea 之前:值打錯是最常見的輸入錯誤, - * 而它在補登之前唯一的症狀就是長度不對,事後從報表上看不出來。 - * @returns {number} epoch 毫秒 - */ -function parseSince(value) { - const at = Date.parse(value); - if (Number.isNaN(at)) { - throw new ScriptError( - 'BAD_SINCE', - `--since 需為可解析的 ISO 8601 時間(例如 2026-09-17T10:05:00Z),收到的是 ${value}`, - ); - } - return at; -} diff --git a/scripts/timer.js b/scripts/timer.js deleted file mode 100644 index 69df1d9..0000000 --- a/scripts/timer.js +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env node -/** - * 起錶與停錶。 - * - * 錶與領取鎖是兩件事:鎖用 assignee 加標籤(見 claim.js),錶只管工時。 - * - * **領取工作包時**(sdlc-feat)兩者的時機不同:鎖要在開工之前就上好,錶則要等到 - * **工作樹真的建好之後**才起。工作樹建立失敗會中止整個領取,錶要是先起了,使用者就被 - * 計了一段什麼都沒做的時間,而工時要準正是工時報表的立足點。規劃與分析沒有工作樹, - * 那條規則對它們不適用——它們的標的是需求議題本身,議題存在就起得了錶。 - * - * 自己的錶跑在別顆議題上時擋下,不代勞停錶:那一段時間該記在哪顆議題上只有人知道, - * 腳本自作主張會把工時記錯地方。錶已經跑在本議題上則什麼都不做——重新起錶會把已經 - * 累積的時間切成兩段,而中斷後重跑正是這支腳本最常見的處境。 - * - * `--stop` 停錶,而且**只停 `--index` 指的那一顆**。每個階段停掉自己起的那支錶, - * 錶就不會跨階段跑——跑完就去開會而錶跑一整天,報表當場失真。反過來,別顆議題上的錶 - * 一律不碰:Gitea 在別顆議題上起新錶會靜默地停掉並記錄前一顆,那種靜默結算正是 - * 領取鎖那條規則當初要擋的,這裡不能反過來製造它。 - * - * 停錶時錶本來就沒在跑不算失敗:這一步多半排在別的事情做完之後(開完 PR、回報之前), - * 把「本來就沒在跑」報成失敗,只會讓人以為前面那件事沒做成而重跑一次。 - * - * 用法: - * node scripts/timer.js --repo owner/name --index 40 [--stop] [--host <網址>] [--dry-run] - */ -import { - expectOk, - fetchIssue, - giteaRequest, - listStopwatches, - main, - parseFlags, - parseIndex, - parseRepo, - preflight, - resolveLogin, - stopStopwatch, - stopwatchElsewhere, - stopwatchOnIssue, -} from './lib.js'; - -main(async () => { - const flags = parseFlags(process.argv.slice(2), { - required: ['repo', 'index'], - optional: ['host'], - booleans: ['stop', 'dry-run'], - }); - const repo = parseRepo(flags.repo); - const index = parseIndex(flags.index); - const issuePath = `/repos/${repo}/issues/${index}`; - const dryRun = flags['dry-run'] === true; - const 要停錶 = flags.stop === true; - - const login = resolveLogin({ host: flags.host }); - // 試跑照樣讀現況:手寫一份固定的清單會跟實作走鐘,也說不出「這顆已經在計時了」 - if (!dryRun) await preflight(login, repo); - const issue = await fetchIssue(login, repo, index); - - const watches = await listStopwatches(login); - const 已在計時 = stopwatchOnIssue(watches, repo, index) !== null; - // 起錶才要擋:別顆議題上的錶會讓工時記錯地方。停錶只動這一顆,擋不擋都影響不到它 - if (!要停錶 && !已在計時 && watches.length > 0) throw stopwatchElsewhere(watches[0], '起錶'); - - // 起錶:已經在跑就不重起,重新起錶會把已經累積的時間切成兩段 - // 停錶:沒在這顆上跑就沒得停,別顆議題上的錶不碰 - const 動作 = 要停錶 - ? { 端點: 'stop', 要發請求: 已在計時 } - : { 端點: 'start', 要發請求: !已在計時 }; - const planned = 動作.要發請求 - ? [{ method: 'POST', path: `${issuePath}/stopwatch/${動作.端點}`, body: {} }] - : []; - - const 報告 = { repo, index: issue.number, title: issue.title, url: issue.html_url }; - - if (dryRun) { - return { dryRun: true, ...報告, requests: planned, 已在計時 }; - } - - if (要停錶) { - // 端點回「沒有錶在跑」的狀態碼隨站台版本而異,所以停錶走 lib 那條容錯路徑; - // 讀到的現況與實際狀態差一步(錶剛被別處停掉)也不該把整件事報成失敗 - const stopped = planned.length > 0 && (await stopStopwatch(login, repo, index)); - return { - ...報告, - 碼錶已停: stopped, - ...(stopped ? {} : { note: '碼錶本來就沒在這顆議題上運轉,這一步略過;別顆議題上的錶不由這裡代停。' }), - }; - } - - for (const { method, path, body } of planned) { - expectOk(await giteaRequest(login, method, path, { body }), `${method} ${path}`); - } - - return { ...報告, 碼錶中: true, 已在計時 }; -}); diff --git a/scripts/wp-extract.js b/scripts/wp-extract.js index 5486510..ae6f25c 100644 --- a/scripts/wp-extract.js +++ b/scripts/wp-extract.js @@ -6,7 +6,7 @@ * 1. 待辦是巢狀的——每一項待辦底下掛它自己的驗收,並各自帶回未經修改的 `raw`, * 下游靠 `raw` 做精確字串替換來勾選 checkbox,只改那一行,不重寫整份 body。 * 2. 介面契約是四欄表格,四欄都要留著。 - * 3. body 說不出的三個活狀態要現查:相依、領取人、碼錶。 + * 3. body 說不出的兩個活狀態要現查:相依與領取人。 * * 用法: * node scripts/wp-extract.js --repo owner/name --index 9 [--host <網址>] [--dry-run] @@ -15,7 +15,6 @@ import { UNMERGED_COMMENT_NOTE, countUnmergedComments, fetchIssue, - listStopwatches, main, pages, parseFlags, @@ -23,7 +22,6 @@ import { parseRepo, preflight, resolveLogin, - stopwatchOnIssue, } from './lib.js'; import { checklistInSection, @@ -56,7 +54,6 @@ main(async () => { { method: 'GET', path: issuePath }, { method: 'GET', path: `${issuePath}/dependencies` }, { method: 'GET', path: `${issuePath}/blocks` }, - { method: 'GET', path: '/user/stopwatches' }, { method: 'GET', path: `${issuePath}/comments` }, ], note: UNMERGED_COMMENT_NOTE, @@ -86,8 +83,6 @@ main(async () => { 整體驗收: listSection(sections, '整體驗收'), repos: listSection(sections, 'repo 列表'), 相依: { blocks, depends }, - assignee: issue.assignee?.login ?? null, - 碼錶中: await hasRunningStopwatch(login, repo, index), 未處理留言數: await countUnmergedComments(login, repo, index, user.login), }; }); @@ -110,13 +105,3 @@ async function fetchLinked(login, path, kind) { return indexes; } -/** - * 這顆議題上是不是有碼錶在跑。 - * - * Gitea 只讓人讀自己的碼錶(`/user/stopwatches`),所以這個欄位的真正語意是 - * 「**我**的碼錶正跑在這顆議題上」。它用來提醒自己忘了停錶,不是用來判斷別人有沒有在做 - * ——領取鎖看的是 assignee。 - */ -async function hasRunningStopwatch(login, repo, index) { - return stopwatchOnIssue(await listStopwatches(login), repo, index) !== null; -} diff --git a/templates/report.md b/templates/report.md index 24229a4..8cbd4af 100644 --- a/templates/report.md +++ b/templates/report.md @@ -1,25 +1,7 @@ -# 工時報表 {{期間}} +# 工時報表 -{{範圍}} +**不可用** -## 總計 +週報、月報、年報目前不可用;時間追蹤功能已移除。 -| 實際工時 | 估算人天 | 已估實際 | 落差 | -| --- | --- | --- | --- | -| {{實際工時}} | {{估算人天}} | {{已估實際}} | {{落差}} | - -## 分段小計 - -| 段 | 起迄 | 實際工時 | -| --- | --- | --- | -{{分段}} - -## 逐議題 - -| 議題 | 標題 | 實際工時 | 估算人天 | 落差 | -| --- | --- | --- | --- | --- | -{{議題}} - -## 附註 - -{{附註}} +狀態碼:`REPORT_UNAVAILABLE` diff --git a/test/claim.test.js b/test/claim.test.js deleted file mode 100644 index 72a37e1..0000000 --- a/test/claim.test.js +++ /dev/null @@ -1,312 +0,0 @@ -/** - * 領取工作包的鎖。 - * - * 錶不在這一支起——它等工作樹建好之後才由 timer.js 起動,所以這裡連帶要驗 - * 「一發起錶請求都沒有」:領取失敗或工作樹建不起來時,使用者不該被計一段 - * 什麼都沒做的時間。 - * - * 這一支的價值全在「什麼時候擋下來」:放行的路徑只有一條,擋的理由有四種, - * 而擋錯的代價是兩個人做同一件事、或是工時記到別顆議題上。所以決策表的四種狀態 - * 各有測試,而且每一種都要驗「一個字都沒寫進 Gitea」——擋下來卻已經改了一半, - * 比直接放行更難收拾。 - */ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { runScript } from './helpers/run-script.js'; -import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js'; - -const REPO = 'plugins/tea-sdlc'; -const INDEX = 11; -const ME = 'tester'; - -/** 議題上跑著的碼錶長什麼樣 */ -const stopwatchOn = (index, repo = REPO) => ({ - issue_index: index, - repo_owner_name: repo.split('/')[0], - repo_name: repo.split('/')[1], -}); - -function routes(overrides = {}, options = {}) { - const { assignees = [], labels = [], stopwatches = [], repoLabels } = options; - - const base = healthyRoutes(REPO, { - 'GET /api/v1/user': { status: 200, body: { login: ME } }, - [`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { - status: 200, - body: { - number: INDEX, - title: '以 sdlc-feat 領取工作包、起錶並備妥分支', - html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/${INDEX}`, - assignees: assignees.map((login) => ({ login })), - labels: labels.map((name, i) => ({ id: 60 + i, name })), - }, - }, - 'GET /api/v1/user/stopwatches': { status: 200, body: stopwatches }, - [`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 201, body: {} }, - [`POST /api/v1/repos/${REPO}/issues/${INDEX}/labels`]: { status: 200, body: [] }, - [`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/start`]: { status: 201, body: {} }, - }); - if (repoLabels !== undefined) { - base[`GET /api/v1/repos/${REPO}/labels`] = { - status: 200, - body: repoLabels.map((name, i) => ({ id: 55 + i, name })), - }; - } - return { ...base, ...overrides }; -} - -const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options)); - -const run = (args, stub) => - runScript('claim.js', ['--repo', REPO, '--index', String(INDEX), ...args], { - env: envFor(stub), - }); - -/** - * 會改動 Gitea 的請求;擋下來的情境裡這些一個都不該出現。 - * 前置檢查對 `issues/0` 的那一發 PATCH 不算數——它是探權限用的,打在一顆不存在的議題上, - * 不會改動任何東西(見 lib.js 的 checkIssueWrite)。 - */ -const writes = (stub) => - stub.requests.filter((r) => r.method !== 'GET').filter((r) => !r.path.endsWith('/issues/0')); - -// ── 決策表:無鎖 ─────────────────────────────────────────────────── - -test('沒有鎖時放行:設 assignee、貼進行中', async (t) => { - const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent', '進行中'] }); - - const { code, json } = await run([], stub); - - assert.equal(code, 0); - assert.equal(json.data.assignee, ME); - assert.deepEqual(json.data.labels, ['進行中']); - assert.equal(json.data.碼錶中, false, '鎖上好了,錶還沒起'); - assert.equal(json.data.已認領過, false); -}); - -test('放行時只寫入鎖的那兩件事,一發起錶請求都沒有', async (t) => { - const stub = await withStub(t, {}, { repoLabels: ['進行中'] }); - - await run([], stub); - - assert.deepEqual( - writes(stub).map((r) => `${r.method} ${r.path}`), - [ - `PATCH /api/v1/repos/${REPO}/issues/${INDEX}`, - `POST /api/v1/repos/${REPO}/issues/${INDEX}/labels`, - ], - '錶等工作樹建好之後才由 timer.js 起動:建不起來就中止,不該已經計了時間', - ); -}); - -test('assignee 送的是自己的帳號,標籤送的是 id 不是名字', async (t) => { - const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent', '進行中'] }); - - await run([], stub); - - const patch = writes(stub).find((r) => r.method === 'PATCH'); - assert.deepEqual(patch.body.assignees, [ME]); - - const label = writes(stub).find((r) => r.path.endsWith('/labels')); - assert.deepEqual(label.body.labels, [56], '進行中在假 repo 上的 id 是 56'); -}); - -// ── 決策表:他人已認領 ───────────────────────────────────────────── - -test('他人已認領時擋下,並指名是誰', async (t) => { - const stub = await withStub(t, {}, { assignees: ['someone-else'], repoLabels: ['進行中'] }); - - const { code, json } = await run([], stub); - - assert.equal(code, 1); - assert.equal(json.error.code, 'CLAIMED_BY_OTHER'); - assert.match(json.error.message, /someone-else/); - assert.deepEqual(writes(stub), [], '擋下來就不該寫進任何東西'); -}); - -test('自己在 assignee 裡但還有別人時,一樣擋', async (t) => { - const stub = await withStub(t, {}, { assignees: [ME, 'someone-else'], repoLabels: ['進行中'] }); - - const { json } = await run([], stub); - - assert.equal(json.error.code, 'CLAIMED_BY_OTHER'); -}); - -// ── 決策表:自己的碼錶在跑 ───────────────────────────────────────── - -test('自己碼錶跑在本議題時擋下,要求先手動停錶', async (t) => { - const stub = await withStub(t, {}, { - stopwatches: [stopwatchOn(INDEX)], - repoLabels: ['進行中'], - }); - - const { code, json } = await run([], stub); - - assert.equal(code, 1); - assert.equal(json.error.code, 'STOPWATCH_ON_THIS_ISSUE'); - assert.match(json.error.message, /停/, '要說清楚下一步是手動停錶'); - assert.match(json.error.message, /工作樹/, '要明說停錶不會動到既有的工作樹'); - assert.deepEqual(writes(stub), []); -}); - -test('自己碼錶跑在別的議題時擋下,並指出是哪一顆', async (t) => { - const stub = await withStub(t, {}, { - stopwatches: [stopwatchOn(7)], - repoLabels: ['進行中'], - }); - - const { code, json } = await run([], stub); - - assert.equal(code, 1); - assert.equal(json.error.code, 'STOPWATCH_ON_OTHER_ISSUE'); - assert.match(json.error.message, /#7/, '忘了停掉的是哪一顆,要指名'); - assert.match( - json.error.message, - /停錶只停計時,不會動到任何既有的工作樹/, - '以為停錶等於放棄那顆工作包的人會寧可不停,工時就記到別顆去了', - ); - assert.deepEqual(writes(stub), []); -}); - -test('別的 repo 上的同號碼錶也算自己有錶在跑', async (t) => { - const stub = await withStub(t, {}, { - stopwatches: [stopwatchOn(INDEX, 'plugins/別的專案')], - repoLabels: ['進行中'], - }); - - const { json } = await run([], stub); - - assert.equal(json.error.code, 'STOPWATCH_ON_OTHER_ISSUE'); - assert.match(json.error.message, /別的專案/); -}); - -// ── 冪等:中斷後重跑 ─────────────────────────────────────────────── - -test('自己已認領但沒有錶時放行,並如實說這顆本來就是自己的', async (t) => { - const stub = await withStub(t, {}, { assignees: [ME], repoLabels: ['進行中'] }); - - const { code, json } = await run([], stub); - - assert.equal(code, 0); - assert.equal(json.data.已認領過, true); - assert.equal(json.data.碼錶中, false, '重跑時鎖照樣補齊,錶則仍舊留到工作樹建好之後'); -}); - -test('進行中標籤已經在議題上時不重複貼', async (t) => { - const stub = await withStub(t, {}, { - assignees: [ME], - labels: ['進行中'], - repoLabels: ['進行中'], - }); - - const { json } = await run([], stub); - - assert.equal(json.data.已認領過, true); - assert.deepEqual(json.data.labels, ['進行中']); - assert.equal( - writes(stub).some((r) => r.path.endsWith('/labels')), - false, - '已經貼著的標籤不必再貼一次', - ); -}); - -// ── 標籤:本 plugin 不自動建立標籤 ───────────────────────────────── - -test('repo 上沒有進行中標籤時擋在寫入之前,並指出該去建哪一個', async (t) => { - const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent'] }); - - const { code, json } = await run([], stub); - - assert.equal(code, 1); - assert.equal(json.error.code, 'LABEL_NOT_FOUND'); - assert.match(json.error.message, /進行中/); - assert.deepEqual(writes(stub), [], '標籤缺了就整件事不做,不要只設一半的鎖'); -}); - -// ── 錯誤 ─────────────────────────────────────────────────────────── - -test('議題不存在時回傳可區分的錯誤碼', async (t) => { - const stub = await withStub(t, { - [`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 404, body: { message: 'not found' } }, - }, { repoLabels: ['進行中'] }); - - const { json } = await run([], stub); - - assert.equal(json.error.code, 'ISSUE_NOT_FOUND'); -}); - -test('--index 不是正整數時擋在打 Gitea 之前', async (t) => { - const stub = await withStub(t, {}, { repoLabels: ['進行中'] }); - - const { json } = await runScript('claim.js', ['--repo', REPO, '--index', '0'], { - env: envFor(stub), - }); - - assert.equal(json.error.code, 'BAD_INDEX'); - assert.equal(stub.requests.length, 0); -}); - -// ── --dry-run ───────────────────────────────────────────────────── - -test('--dry-run 印出將發出的寫入,但一個字都不寫進去', async (t) => { - const stub = await withStub(t, {}, { repoLabels: ['進行中'] }); - - const { code, json } = await run(['--dry-run'], stub); - - assert.equal(code, 0); - assert.equal(json.data.dryRun, true); - assert.deepEqual( - json.data.requests.map((r) => `${r.method} ${r.path}`), - [ - `PATCH /repos/${REPO}/issues/${INDEX}`, - `POST /repos/${REPO}/issues/${INDEX}/labels`, - ], - ); - assert.deepEqual(writes(stub), [], '預覽不得真的寫入'); -}); - -test('--dry-run 會先讀現況:預覽出來的是這一顆實際的處境', async (t) => { - // 手寫一份固定的清單很容易跟實作走鐘,而且說不出「這顆已經是你的了」這種事 - const stub = await withStub(t, {}, { repoLabels: ['進行中'] }); - - await run(['--dry-run'], stub); - - const reads = stub.requests.filter((r) => r.method === 'GET').map((r) => r.path); - assert.ok(reads.includes('/api/v1/user')); - assert.ok(reads.includes(`/api/v1/repos/${REPO}/issues/${INDEX}`)); - assert.ok(reads.includes('/api/v1/user/stopwatches')); - assert.ok(reads.includes(`/api/v1/repos/${REPO}/labels`)); -}); - -test('--dry-run 略過已經做好的部分:鎖都在了就什麼都不必寫', async (t) => { - const stub = await withStub(t, {}, { - assignees: [ME], - labels: ['進行中'], - repoLabels: ['進行中'], - }); - - const { json } = await run(['--dry-run'], stub); - - assert.deepEqual( - json.data.requests, - [], - 'assignee 與標籤都已經到位,中斷重跑就是走到這裡;手寫一份固定的清單會謊報', - ); -}); - -test('--dry-run 在鎖擋得住的情況下照樣擋,這才是預覽的用處', async (t) => { - const stub = await withStub(t, {}, { assignees: ['someone-else'], repoLabels: ['進行中'] }); - - const { code, json } = await run(['--dry-run'], stub); - - assert.equal(code, 1); - assert.equal(json.error.code, 'CLAIMED_BY_OTHER'); -}); - -test('--dry-run 遇到缺標籤一樣報錯,不會等到實跑才發現', async (t) => { - const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent'] }); - - const { json } = await run(['--dry-run'], stub); - - assert.equal(json.error.code, 'LABEL_NOT_FOUND'); -}); diff --git a/test/delegation-assets.test.js b/test/delegation-assets.test.js deleted file mode 100644 index 8262677..0000000 --- a/test/delegation-assets.test.js +++ /dev/null @@ -1,220 +0,0 @@ -/** - * 委派:判準正本、正本上的標記,以及兩者之間那條雙向斷言。 - * - * 判準與標記分住兩個檔案,而它們講的是同一件事。沒有雙向斷言,兩邊會慢慢漂開—— - * 而漂開的時候不會有任何東西報錯:正本上多標一步不會壞,判準表少列一項也不會壞, - * 只是下一個讀的人會以為自己讀到的是全部。 - */ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { - DELEGATABLE, - PLATFORM_SPECIFIC, - promptSteps, - readPrompt, - readReference, -} from './helpers/prompt-doc.js'; - -const PROMPTS = ['sdlc-plan', 'sdlc-analyze', 'sdlc-feat', 'sdlc-fix', 'sdlc-sync', 'sdlc-report']; -const reference = readReference('delegation'); - -/** 帶標記的三份正本;另外三份目前沒有可委派的步驟 */ -const 有標記的正本 = ['sdlc-plan', 'sdlc-analyze', 'sdlc-feat']; - -/** `正本/步驟` 這種好讀的鍵,比對失敗時看得出差在哪一步 */ -const 鍵 = ({ prompt, name }) => `${prompt}/${name}`; - -/** delegation.md 那張表列出來的步驟 */ -const 表上的 = () => - [...reference.matchAll(/^\| `(sdlc-[a-z]+)` \| (.+?) \| (.+?) \|$/gm)].map((m) => ({ - prompt: m[1], - name: m[2].trim(), - 範圍: m[3].trim(), - })); - -/** 正本上實際被標記的步驟 */ -const 正本上的 = () => - PROMPTS.flatMap((prompt) => - promptSteps(readPrompt(prompt)) - .filter((step) => step.marked) - .map((step) => ({ prompt, name: step.name, body: step.body })), - ); - -// ── 判準正本 ─────────────────────────────────────────────────────── - -test('delegation.md 的開頭形狀與既有規則正本一致', () => { - assert.equal(reference.startsWith('# '), true, '規則正本一律以 H1 起頭,不放 frontmatter'); - assert.match(reference.split('\n')[0], /委派/); -}); - -/** 判準那一節的四條,各自含標題與理由 */ -function 判準逐條() { - const 節 = reference.slice(reference.indexOf('## 判準'), reference.indexOf('## 怎麼委派')); - return 節.split(/^(?=\d+\. \*\*)/m).filter((one) => /^\d+\. \*\*/.test(one)); -} - -test('四條判準逐條載明,一條不多一條不少', () => { - const 判準 = reference.slice(reference.indexOf('## 判準'), reference.indexOf('## 怎麼委派')); - const 條 = 判準逐條().map((one) => /^\d+\. \*\*(.+?)\*\*/.exec(one)[1]); - assert.equal(條.length, 4, `判準應為四條,目前 ${條.length} 條:${條.join('、')}`); - assert.match(判準, /可驗證的成品/); - assert.match(判準, /不會詢問使用者/); - assert.match(判準, /失敗能被呼叫端偵測/); - assert.match(判準, /不直接寫入 Gitea 或 git/); -}); - -test('第二條與第四條各自標明是硬排除,並各自寫出理由', () => { - // 數「硬排除」出現幾次的話,別處多提一句就會失敗;要問的是「那兩條上面有沒有」 - const [, 二, , 四] = 判準逐條(); - assert.match(二, /硬排除/, '第二條是硬排除,不是建議'); - assert.match(二, /子代理問不到使用者/, '沒寫理由的話,下一個人會把它當成建議而繞過去'); - assert.match(四, /硬排除/, '第四條是硬排除,不是建議'); - assert.match(四, /失敗沒有人看著/, '理由不是子代理做不好,要寫清楚,否則會被當成不信任'); -}); - -// ── 雙向斷言 ─────────────────────────────────────────────────────── - -test('正本上被標記的集合,等於 delegation.md 列出的集合', () => { - const 表 = 表上的().map(鍵).sort(); - const 正本 = 正本上的().map(鍵).sort(); - assert.deepEqual(正本, 表, '改一邊就要改另一邊,否則兩份說法會漂開'); -}); - -// 這一條與上一條刻意重複:雙向斷言只保證兩邊一致,兩邊一起改就一起漂走。 -// 把議題點名的那六個逐字釘在這裡,改動才需要有人明確地改掉這份清單。 -test('被標記的正好是議題點名的那六個', () => { - assert.deepEqual(正本上的().map(鍵).sort(), [ - 'sdlc-analyze/算出截止日', - 'sdlc-analyze/對四份清單列出疑點', - 'sdlc-analyze/產生分析版的圖解總覽', - 'sdlc-feat/把議題標題翻成英文', - 'sdlc-feat/分批提交', - 'sdlc-plan/產生圖解版總覽', - ].sort()); -}); - -// ── 判準第二條的迴歸保護 ─────────────────────────────────────────── - -/** 會問使用者的步驟。子代理問不到人,這些永遠不該被標上可委派。 */ -const 會問使用者 = [ - ['sdlc-plan', '逐項詢問'], - ['sdlc-analyze', '逐題問到共識'], - ['sdlc-feat', '問來源分支'], - ['sdlc-feat', '認出語言,讀規則正本'], -]; - -test('點名的問到共識類步驟一律未被標記', () => { - for (const [prompt, name] of 會問使用者) { - const step = promptSteps(readPrompt(prompt)).find((one) => one.name === name); - assert.ok(step, `${prompt} 少了「${name}」這一步;步驟改名的話這份清單要跟著改`); - assert.equal(step.marked, false, `${prompt}/${name} 會問使用者,判準第二條硬排除`); - } -}); - -test('任何看得出在問使用者的步驟都沒有被標記', () => { - // 只認明確的提問語,不認「不要拿去問使用者」那種否定句——那句正好出現在可委派的步驟裡 - const 提問語 = /一次問一題|停下來問|等使用者回答|問到共識|問過使用者/; - let 掃過 = 0; - let 認出 = 0; - - for (const prompt of PROMPTS) { - for (const step of promptSteps(readPrompt(prompt))) { - 掃過 += 1; - if (!提問語.test(step.body)) continue; - 認出 += 1; - assert.equal(step.marked, false, `${prompt}/${step.name} 在問使用者,不該標可委派`); - } - } - - // 兩道自我檢查:這種掃描最常見的壞法是「一條都沒掃到」,而那時它照樣是綠的。 - // 目前有編號步驟的是 plan/analyze/feat/report 四份,sdlc-fix 與 sdlc-sync 沒有編號步驟 - assert.ok(掃過 >= 40, `只掃到 ${掃過} 個步驟,正本的步驟標題格式可能變了`); - assert.ok(認出 >= 4, `提問語一個步驟都沒認出來(${認出}),這道保護已經形同虛設`); -}); - -// ── 判準第四條:不直接寫入 ───────────────────────────────────────── - -/** 會寫入 Gitea 或 git 的腳本。被標記的步驟碰到它們,就要寫明哪一半不委派。 */ -const 寫入型 = [ - 'issue-create', - 'issue-update', - 'issue-link', - 'project-add', - 'pr-create', - 'claim.js', - 'branch-prep', - 'worktree-ensure', - 'worktree-remove', - 'timer.js', - 'time-log.js', - 'commit-split', -]; - -test('被標記的步驟若碰得到寫入,就要寫明哪一半不委派', () => { - for (const step of 正本上的()) { - const 碰到 = 寫入型.filter((script) => step.body.includes(script)); - if (碰到.length === 0) continue; - assert.match( - step.body, - /不委派/, - `${鍵(step)} 用到 ${碰到.join('、')},要寫明那一半留給主流程`, - ); - } -}); - -test('三步部分委派的範圍,判準表上也說得出來', () => { - const 部分 = 表上的().filter((one) => one.範圍 !== '全步'); - assert.equal(部分.length, 3, '兩份圖解總覽與分批提交是部分委派'); - for (const one of 部分) { - assert.match(one.範圍, /不委派/, `${鍵(one)} 的範圍要說出哪一半不委派`); - } -}); - -test('另外三份正本一個標記都沒有,與判準正本結尾那句話一致', () => { - for (const prompt of PROMPTS.filter((one) => !有標記的正本.includes(one))) { - assert.equal( - readPrompt(prompt).includes(DELEGATABLE), - false, - `${prompt} 出現了標記,但 delegation.md 結尾說它沒有可委派的步驟`, - ); - } - assert.match(reference, /`sdlc-sync`、`sdlc-fix` 與 `sdlc-report` 目前沒有可委派的步驟/); -}); - -// ── 平台中立 ─────────────────────────────────────────────────────── - -test('判準正本與標記都不指名任何平台的工具', () => { - for (const token of PLATFORM_SPECIFIC) { - assert.equal(reference.includes(token), false, `delegation.md 不該出現平台專屬字樣:${token}`); - } - // 子代理是平台專屬能力,正本只能以能力描述帶過 - assert.match(reference, /能力描述/); - assert.match(reference, /不能就自己做/); -}); - -test('三份帶標記的正本各自說明了這個後綴是什麼意思,並指名判準正本', () => { - for (const prompt of 有標記的正本) { - const text = readPrompt(prompt); - assert.match(text, new RegExp(`## ${DELEGATABLE}的意思`), `${prompt} 要解釋這個後綴`); - assert.match(text, /references\/delegation\.md/, `${prompt} 要指名判準正本,不要把判準抄過去`); - assert.match(text, /不能就自己做/, `${prompt} 要寫成能力描述,讓不支援的平台自然降級`); - } -}); - -test('標記是標題後綴,不用 emoji 也不用 HTML 註解', () => { - for (const prompt of PROMPTS) { - const text = readPrompt(prompt); - assert.equal(text.includes('