From 43764f457e763e134b7b4a395c5329143b43947e Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 16:59:30 +0800 Subject: [PATCH 1/5] =?UTF-8?q?refactor(pr-comments):=20=E4=B8=89=E9=A1=9E?= =?UTF-8?q?=E7=95=99=E8=A8=80=E7=9A=84=E8=AE=80=E5=8F=96=E6=94=B6=E9=80=B2?= =?UTF-8?q?=20pr-threads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pr-watch 要數「還有幾則沒處理」,數的是與 pr-comments 完全同一件事:三類留言分散在 三個端點,一般留言與總評看自己打的 +1、行內留言看有沒有被 resolve,而總評的 reaction 掛在它的 issue comment id 上。這套規則寫兩份,遲早會一邊認自己的 +1、另一邊認任何人的 ——而那個差異要等到有留言被靜靜跳過才會被發現。 pr-comments 只留輸入輸出,行為不變。 議題 #41 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/pr-comments.js | 168 ++---------------------------------- scripts/pr-threads.js | 190 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 162 deletions(-) create mode 100644 scripts/pr-threads.js diff --git a/scripts/pr-comments.js b/scripts/pr-comments.js index cae6943..53994bd 100644 --- a/scripts/pr-comments.js +++ b/scripts/pr-comments.js @@ -5,35 +5,23 @@ * 三類分散在三個端點,漏掉任何一類就會有 reviewer 的意見沒被處理——而那正是 * `/sdlc-fix` 存在的理由。這一支只讀不寫,分類(必改/建議)由讀到內容的人判斷。 * - * 「已處理」在三類上的機制不同: - * - 一般留言、review 總評 → 自己打的 `+1` reaction - * - 行內留言 → 有沒有被 resolve(只有 review comment 有 resolve 端點) - * - * **總評的 reaction 掛在它的 issue comment id 上,不是 review id。** Gitea 的 review - * 總評在 issue comment 表裡也有一份,兩個 id 不同命名空間——拿 review id 去打 - * reaction 會 404。那一份的 id 由 timeline 給(`type: 'review'` 的項目帶 `review_id`)。 - * - * reaction 要是**自己**打的才算已處理:reviewer 對留言按讚是「我同意」,不是 - * 「這則我處理過了」,把它當成已處理會讓那一則被靜靜跳過。 + * 讀取本身與「已處理」的判定在 `pr-threads.js`——`pr-watch` 要數同一件事, + * 規則寫兩份遲早會各自演化。 * * 用法: * node scripts/pr-comments.js --repo owner/name --index 45 [--host <網址>] [--dry-run] */ import { - ScriptError, expectOk, giteaRequest, main, - pages, parseFlags, parseIndex, parseRepo, preflight, resolveLogin, } from './lib.js'; - -/** 還沒送出的 review:reviewer 自己都還看不到,不該被當成意見 */ -const DRAFT = 'PENDING'; +import { fetchPull, readPullComments, unhandledCount } from './pr-threads.js'; main(async () => { const flags = parseFlags(process.argv.slice(2), { @@ -67,11 +55,8 @@ main(async () => { await preflight(login, repo); const me = expectOk(await giteaRequest(login, 'GET', '/user'), 'GET /user').login; - const pull = await fetchPull(login, repo, index, pullPath); - const 留言 = [ - ...(await readGeneral(login, repo, index, me)), - ...(await readReviews(login, repo, index, pullPath, me)), - ]; + const pull = await fetchPull(login, repo, index); + const 留言 = await readPullComments(login, repo, index, me); return { repo, @@ -80,148 +65,7 @@ main(async () => { url: pull.html_url, state: pull.state, 留言, - 未處理數: 留言.filter((comment) => !comment.已處理).length, + 未處理數: unhandledCount(留言), }; }); - -async function fetchPull(login, repo, index, path) { - const response = await giteaRequest(login, 'GET', path); - if (response.status === 404) { - throw new ScriptError('PULL_NOT_FOUND', `${repo} 沒有編號 ${index} 的 PR`); - } - if (response.status === 403) { - throw new ScriptError('NO_READ_ACCESS', `目前的帳號沒有 ${repo} 的 PR ${index} 的讀取權`); - } - return expectOk(response, `GET ${path}`); -} - -/** - * 一般留言。PR 在 Gitea 裡也是 issue,所以走 issue 的留言端點。 - * 內容是空的那些多半是狀態變更的系統紀錄(指派、改標題),不是意見。 - */ -async function readGeneral(login, repo, index, me) { - const path = `/repos/${repo}/issues/${index}/comments`; - const 留言 = []; - - for await (const comments of pages(login, path, { - limitCode: 'COMMENT_LIMIT', - limitHint: `${path} 的留言太多,讀不完整份清單`, - })) { - for (const comment of comments) { - if ((comment.body ?? '').trim() === '') continue; - 留言.push({ - id: comment.id, - 類型: '一般', - 作者: comment.user?.login ?? '', - 內容: comment.body, - 已處理: await markedByMe(login, repo, comment.id, me), - 可標記: true, - }); - } - } - return 留言; -} - -/** - * review 的總評與它底下的行內留言。 - * - * 總評的 id 要用它在 issue comment 表裡的那一份(timeline 給),reaction 才打得上去; - * 行內留言則要用 review 自己的 id 去查。兩個 id 都要,所以兩邊都讀。 - */ -async function readReviews(login, repo, index, pullPath, me) { - const path = `${pullPath}/reviews`; - const commentIds = await reviewCommentIds(login, repo, index); - const 留言 = []; - - for await (const reviews of pages(login, path, { - limitCode: 'REVIEW_LIMIT', - limitHint: `${path} 的 review 太多,讀不完整份清單`, - })) { - for (const review of reviews) { - if (review.state === DRAFT) continue; - - const commentId = commentIds.get(review.id); - if ((review.body ?? '').trim() !== '') { - 留言.push({ - id: commentId ?? review.id, - review: review.id, - 類型: '總評', - 作者: review.user?.login ?? '', - 內容: review.body, - // 找不到它在 issue comment 表裡的那一份就標不了——那時如實說,不要假裝可以 - 已處理: commentId === undefined ? false : await markedByMe(login, repo, commentId, me), - 可標記: commentId !== undefined, - }); - } - - const commentsPath = `${path}/${review.id}/comments`; - for await (const comments of pages(login, commentsPath, { - limitCode: 'REVIEW_COMMENT_LIMIT', - limitHint: `${commentsPath} 的行內留言太多,讀不完整份清單`, - })) { - for (const comment of comments) 留言.push(inlineComment(comment, review.id)); - } - } - } - return 留言; -} - -/** - * 一則行內留言。 - * - * 位置分兩側:留在新檔那一側用 `position`,留在被刪掉的那一行用 `original_position`, - * Gitea 只會填其中一個。只讀 position 的話,留在刪除行的留言會得到 undefined, - * 回覆時位置就送錯欄位、落到別的地方去。 - */ -function inlineComment(comment, reviewId) { - const onNew = (comment.position ?? 0) > 0; - return { - id: comment.id, - review: reviewId, - 類型: '行內', - 作者: comment.user?.login ?? '', - 內容: comment.body, - 檔案: comment.path, - 行: onNew ? comment.position : comment.original_position, - 側: onNew ? '新' : '舊', - // 帶上 diff 片段:沒有它,agent 只看得到「這裡少了錯誤處理」而不知道哪裡 - diff: comment.diff_hunk ?? '', - // 回覆要落在同一個 commit 上,否則 PR 之後又推了新 commit 時行號對不上 - commit: comment.commit_id ?? comment.original_commit_id ?? null, - 已處理: comment.resolver != null, - 可標記: true, - }; -} - -/** - * review id → 它在 issue comment 表裡的那一則 id。 - * 總評的 reaction 掛在後者上,而 reviews 端點只給得出前者。 - */ -async function reviewCommentIds(login, repo, index) { - const path = `/repos/${repo}/issues/${index}/timeline`; - const ids = new Map(); - - for await (const entries of pages(login, path, { - limitCode: 'TIMELINE_LIMIT', - limitHint: `${path} 的項目太多,對不齊總評的 reaction`, - })) { - for (const entry of entries) { - if (entry.type === 'review' && entry.review_id) ids.set(entry.review_id, entry.id); - } - } - return ids; -} - -/** - * 這一則是不是「我」標記過已處理。 - * - * Gitea 的留言物件不含 reaction,只能逐則再查一次。認的是自己打的 `+1`: - * reviewer 對留言按讚是「我同意」,當成已處理會讓那一則被靜靜跳過。 - */ -async function markedByMe(login, repo, id, me) { - const path = `/repos/${repo}/issues/comments/${id}/reactions`; - const reactions = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? []; - - return reactions.some((reaction) => reaction.content === '+1' && reaction.user?.login === me); -} diff --git a/scripts/pr-threads.js b/scripts/pr-threads.js new file mode 100644 index 0000000..2d4adb2 --- /dev/null +++ b/scripts/pr-threads.js @@ -0,0 +1,190 @@ +/** + * 讀 PR 上的三類留言:一般留言、review 總評、行內留言。 + * + * 兩支腳本共用這一份:`pr-comments` 把整份交給 `/sdlc-fix` 逐則處理, + * `pr-watch` 只數還有幾則沒處理。判定「已處理」的規則只能有一份——兩邊各寫一次, + * 遲早會一邊認自己打的 `+1`、另一邊認任何人的,而那個差異要等到有留言被靜靜跳過 + * 才會被發現。 + * + * 「已處理」在三類上的機制不同: + * - 一般留言、review 總評 → 自己打的 `+1` reaction + * - 行內留言 → 有沒有被 resolve(只有 review comment 有 resolve 端點) + * + * **總評的 reaction 掛在它的 issue comment id 上,不是 review id。** Gitea 的 review + * 總評在 issue comment 表裡也有一份,兩個 id 不同命名空間——拿 review id 去打 + * reaction 會 404。那一份的 id 由 timeline 給(`type: 'review'` 的項目帶 `review_id`)。 + * + * reaction 要是**自己**打的才算已處理:reviewer 對留言按讚是「我同意」,不是 + * 「這則我處理過了」,把它當成已處理會讓那一則被靜靜跳過。 + */ +import { ScriptError, expectOk, giteaRequest, pages } from './lib.js'; + +/** 還沒送出的 review:reviewer 自己都還看不到,不該被當成意見 */ +const DRAFT = 'PENDING'; + +/** + * 讀齊三類留言。 + * @param {{base: string, token: string}} login + * @param {string} repo owner/name + * @param {number} index PR 編號 + * @param {string} me 自己的帳號,用來認「這則是我標的」 + * @returns {Promise} + */ +export async function readPullComments(login, repo, index, me) { + const pullPath = `/repos/${repo}/pulls/${index}`; + return [ + ...(await readGeneral(login, repo, index, me)), + ...(await readReviews(login, repo, index, pullPath, me)), + ]; +} + +/** 還沒被處理的則數。`/sdlc-fix` 要做的量,也是 `pr-watch` 的建議動作的依據。 */ +export function unhandledCount(留言) { + return 留言.filter((comment) => !comment.已處理).length; +} + +/** + * 讀一顆 PR。「不存在」與「沒有讀取權」要分得開——前者是編號打錯,後者是權限沒開。 + * @returns {Promise} + */ +export async function fetchPull(login, repo, index) { + const path = `/repos/${repo}/pulls/${index}`; + const response = await giteaRequest(login, 'GET', path); + if (response.status === 404) { + throw new ScriptError('PULL_NOT_FOUND', `${repo} 沒有編號 ${index} 的 PR`); + } + if (response.status === 403) { + throw new ScriptError('NO_READ_ACCESS', `目前的帳號沒有 ${repo} 的 PR ${index} 的讀取權`); + } + return expectOk(response, `GET ${path}`); +} + +/** + * 一般留言。PR 在 Gitea 裡也是 issue,所以走 issue 的留言端點。 + * 內容是空的那些多半是狀態變更的系統紀錄(指派、改標題),不是意見。 + */ +async function readGeneral(login, repo, index, me) { + const path = `/repos/${repo}/issues/${index}/comments`; + const 留言 = []; + + for await (const comments of pages(login, path, { + limitCode: 'COMMENT_LIMIT', + limitHint: `${path} 的留言太多,讀不完整份清單`, + })) { + for (const comment of comments) { + if ((comment.body ?? '').trim() === '') continue; + 留言.push({ + id: comment.id, + 類型: '一般', + 作者: comment.user?.login ?? '', + 內容: comment.body, + 已處理: await markedByMe(login, repo, comment.id, me), + 可標記: true, + }); + } + } + return 留言; +} + +/** + * review 的總評與它底下的行內留言。 + * + * 總評的 id 要用它在 issue comment 表裡的那一份(timeline 給),reaction 才打得上去; + * 行內留言則要用 review 自己的 id 去查。兩個 id 都要,所以兩邊都讀。 + */ +async function readReviews(login, repo, index, pullPath, me) { + const path = `${pullPath}/reviews`; + const commentIds = await reviewCommentIds(login, repo, index); + const 留言 = []; + + for await (const reviews of pages(login, path, { + limitCode: 'REVIEW_LIMIT', + limitHint: `${path} 的 review 太多,讀不完整份清單`, + })) { + for (const review of reviews) { + if (review.state === DRAFT) continue; + + const commentId = commentIds.get(review.id); + if ((review.body ?? '').trim() !== '') { + 留言.push({ + id: commentId ?? review.id, + review: review.id, + 類型: '總評', + 作者: review.user?.login ?? '', + 內容: review.body, + // 找不到它在 issue comment 表裡的那一份就標不了——那時如實說,不要假裝可以 + 已處理: commentId === undefined ? false : await markedByMe(login, repo, commentId, me), + 可標記: commentId !== undefined, + }); + } + + const commentsPath = `${path}/${review.id}/comments`; + for await (const comments of pages(login, commentsPath, { + limitCode: 'REVIEW_COMMENT_LIMIT', + limitHint: `${commentsPath} 的行內留言太多,讀不完整份清單`, + })) { + for (const comment of comments) 留言.push(inlineComment(comment, review.id)); + } + } + } + return 留言; +} + +/** + * 一則行內留言。 + * + * 位置分兩側:留在新檔那一側用 `position`,留在被刪掉的那一行用 `original_position`, + * Gitea 只會填其中一個。只讀 position 的話,留在刪除行的留言會得到 undefined, + * 回覆時位置就送錯欄位、落到別的地方去。 + */ +function inlineComment(comment, reviewId) { + const onNew = (comment.position ?? 0) > 0; + return { + id: comment.id, + review: reviewId, + 類型: '行內', + 作者: comment.user?.login ?? '', + 內容: comment.body, + 檔案: comment.path, + 行: onNew ? comment.position : comment.original_position, + 側: onNew ? '新' : '舊', + // 帶上 diff 片段:沒有它,agent 只看得到「這裡少了錯誤處理」而不知道哪裡 + diff: comment.diff_hunk ?? '', + // 回覆要落在同一個 commit 上,否則 PR 之後又推了新 commit 時行號對不上 + commit: comment.commit_id ?? comment.original_commit_id ?? null, + 已處理: comment.resolver != null, + 可標記: true, + }; +} + +/** + * review id → 它在 issue comment 表裡的那一則 id。 + * 總評的 reaction 掛在後者上,而 reviews 端點只給得出前者。 + */ +async function reviewCommentIds(login, repo, index) { + const path = `/repos/${repo}/issues/${index}/timeline`; + const ids = new Map(); + + for await (const entries of pages(login, path, { + limitCode: 'TIMELINE_LIMIT', + limitHint: `${path} 的項目太多,對不齊總評的 reaction`, + })) { + for (const entry of entries) { + if (entry.type === 'review' && entry.review_id) ids.set(entry.review_id, entry.id); + } + } + return ids; +} + +/** + * 這一則是不是「我」標記過已處理。 + * + * Gitea 的留言物件不含 reaction,只能逐則再查一次。認的是自己打的 `+1`: + * reviewer 對留言按讚是「我同意」,當成已處理會讓那一則被靜靜跳過。 + */ +async function markedByMe(login, repo, id, me) { + const path = `/repos/${repo}/issues/comments/${id}/reactions`; + const reactions = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? []; + + return reactions.some((reaction) => reaction.content === '+1' && reaction.user?.login === me); +} -- 2.53.0 From 9e049580bbded70be753ac0bac118978ce177109 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 16:59:30 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(worktree-remove):=20=E6=89=8B=E5=8B=95?= =?UTF-8?q?=E6=B8=85=E6=8E=89=E4=B8=80=E6=A3=B5=E5=B7=A5=E4=BD=9C=E6=A8=B9?= =?UTF-8?q?=EF=BC=8C=E7=B5=95=E4=B8=8D=20--force?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pr-watch 會在 PR 合併或關閉時自動清理,但永遠不會被合併也不會被關閉的 PR 沒有出口 ——沒有這一支,那些工作樹只能靠使用者自己記得去刪。兩條路共用 lib 的 removeWorktree, 不互相開子行程:守門的規則只有一份,自動的那條與手動的這條不該長出兩種行為。 **絕不 --force。** 清理會被自動執行,而自動執行的東西只能做可逆的事:工作樹重建得回來, 被刪掉的未提交變更救不回來。所以有東西沒提交就中止並報出路徑與檔名。只移除工作樹, 本機分支與遠端分支都留著。 輸入是 owner/repo 與分支名而不是一條路徑:要刪哪一棵由「哪顆工作包」決定, 使用者不必自己去記 12 碼的雜湊目錄名。 順手修掉一個測試抓出來的解析錯誤:git status --porcelain 的第一行前導空白會被 runGit 的 trim 修掉,固定切前三個字元會讓已修改檔案的檔名少一個字(README.md 變成 EADME.md), 人照著訊息去找會找不到。改成認「狀態欄一到兩個字元 + 空白」。 議題 #41 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/lib.js | 53 +++++++++++ scripts/worktree-remove.js | 91 +++++++++++++++++++ test/worktree-remove.test.js | 166 +++++++++++++++++++++++++++++++++++ 3 files changed, 310 insertions(+) create mode 100644 scripts/worktree-remove.js create mode 100644 test/worktree-remove.test.js diff --git a/scripts/lib.js b/scripts/lib.js index 914c4d0..74d3964 100644 --- a/scripts/lib.js +++ b/scripts/lib.js @@ -446,6 +446,59 @@ export function openGitRepo(path) { return (...args) => runGit(args, { cwd: path }); } +/** + * 看一棵工作樹現在是什麼狀況。 + * + * 清理前要先知道三件事:它還在不在、裡面有沒有沒提交的東西、以及那條路徑上的東西 + * 到底是不是一棵工作樹(別的 clone 也可能在同一條路徑上留下目錄——路徑由 + * owner/repo/分支名 推導,不含本機 clone 的位置)。 + * + * @param {string} worktree 推導出的工作樹路徑 + * @returns {{path: string, exists: boolean, worktree: boolean, dirty: boolean, files: string[]}} + */ +export function inspectWorktree(worktree) { + const 空的 = { path: worktree, exists: false, worktree: false, dirty: false, files: [] }; + if (!existsSync(worktree)) return 空的; + // 工作樹的 .git 是一個檔案(指回主 repo),不是目錄;沒有它就不是 git 認得的工作樹 + if (!existsSync(join(worktree, '.git'))) { + return { ...空的, exists: true }; + } + + const files = runGit(['status', '--porcelain'], { cwd: worktree }) + .split('\n') + .filter((line) => line !== '') + // 狀態欄是一到兩個字元,後面接空白才是檔名。不能固定切掉前三個字元—— + // runGit 修掉了整段輸出的前後空白,第一行的「已修改」那個前導空白也跟著沒了, + // 切太多會讓檔名少一個字(README.md 變成 EADME.md),人照著去找會找不到。 + .map((line) => line.replace(/^\s*\S{1,2}\s+/, '')); + + return { path: worktree, exists: true, worktree: true, dirty: files.length > 0, files }; +} + +/** + * 移除一棵工作樹。自動清理與手動出口共用這一份實作,不互相開子行程。 + * + * **絕不 `--force`。** 這件事會被 pr-watch 自動執行,而自動執行的東西只能做可逆的事: + * 工作樹重建得回來,被刪掉的未提交變更救不回來。所以有東西沒提交時就回報擋下的原因, + * 由呼叫端決定要報成錯誤(手動清理)還是一個待處理的建議(自動監看)。 + * + * 只移除工作樹,**本機分支與遠端分支都保留**:本機分支不佔什麼空間,留著讓使用者 + * 還能回頭看那段歷史;遠端分支要不要刪是 Gitea 合併時的選項,由使用者自己決定。 + * + * @param {string} worktree 推導出的工作樹路徑 + * @returns {{removed: boolean, reason: 'removed'|'missing'|'dirty'|'foreign', files: string[], path: string}} + */ +export function removeWorktree(worktree) { + const state = inspectWorktree(worktree); + if (!state.exists) return { ...state, removed: false, reason: 'missing' }; + if (!state.worktree) return { ...state, removed: false, reason: 'foreign' }; + if (state.dirty) return { ...state, removed: false, reason: 'dirty' }; + + // 在工作樹自己裡面執行:它的 .git 指得回主 repo,呼叫端因此不必知道主 clone 在哪 + runGit(['worktree', 'remove', worktree], { cwd: worktree }); + return { ...state, removed: true, reason: 'removed' }; +} + // ── 四層前置檢查 ─────────────────────────────────────────────────── /** diff --git a/scripts/worktree-remove.js b/scripts/worktree-remove.js new file mode 100644 index 0000000..cc2b59c --- /dev/null +++ b/scripts/worktree-remove.js @@ -0,0 +1,91 @@ +#!/usr/bin/env node +/** + * 手動清掉一棵工作樹。 + * + * pr-watch 會在 PR 合併或關閉時自動清理,這一支是給那些**永遠不會被合併也不會被關閉** + * 的 PR 用的出口——沒有它,那些工作樹只能靠使用者自己記得去刪。 + * + * 兩條路共用 lib 的 `removeWorktree`,不互相開子行程:守門的規則只有一份, + * 自動的那條與手動的這條不該長出兩種行為。 + * + * 只移除工作樹,本機分支與遠端分支都留著。工作樹裡還有沒提交的東西就中止並報出路徑, + * **絕不 `--force`**:工作樹重建得回來,被刪掉的未提交變更救不回來。 + * + * 路徑由 `owner/repo/分支名` 推導,所以輸入是這兩個而不是一條路徑——要刪哪一棵由 + * 「哪顆工作包」決定,使用者不必自己去記 12 碼的雜湊目錄名。 + * + * 用法: + * node scripts/worktree-remove.js --repo owner/name --branch <分支名> [--dry-run] + */ +import { + ScriptError, + inspectWorktree, + main, + parseFlags, + parseRepo, + removeWorktree, + worktreePath, +} from './lib.js'; + +main(async () => { + const flags = parseFlags(process.argv.slice(2), { + required: ['repo', 'branch'], + booleans: ['dry-run'], + }); + const repo = parseRepo(flags.repo); + const branch = flags.branch; + const worktree = worktreePath(repo, branch); + + // 試跑與實跑走同一條守門:試跑印得出漂亮的計畫、實跑卻被擋下來,是最難查的那種落差 + if (flags['dry-run']) { + const state = inspectWorktree(worktree); + checkRemovable({ ...state, reason: reasonOf(state) }, worktree); + return { + dryRun: true, + repo, + branch, + worktree, + 已經不在: !state.exists, + commands: state.exists ? [`git worktree remove ${worktree}`] : [], + }; + } + + const result = removeWorktree(worktree); + checkRemovable(result, worktree); + + return { + repo, + branch, + worktree, + removed: result.removed, + // 本來就不在不算失敗:重跑這一支是常態,而結果一樣是「那棵樹不在了」 + 已經不在: result.reason === 'missing', + }; +}); + + +/** inspectWorktree 的結果換算成 removeWorktree 用的同一組原因,讓試跑與實跑擋在同一處 */ +function reasonOf(state) { + if (!state.exists) return 'missing'; + if (!state.worktree) return 'foreign'; + return state.dirty ? 'dirty' : 'removed'; +} + +/** 擋下來的兩種情況各有各的下一步,錯誤碼要分得開 */ +function checkRemovable(result, worktree) { + if (result.reason === 'dirty') { + throw new ScriptError( + 'WORKTREE_DIRTY', + `工作樹 ${worktree} 裡還有沒提交的東西(${result.files.join('、')});` + + '請先提交、暫存(git stash)或確認可以丟掉再自己刪除——' + + '本工具不會加 --force,刪掉的未提交變更救不回來', + ); + } + if (result.reason === 'foreign') { + throw new ScriptError( + 'NOT_A_WORKTREE', + `${worktree} 上有東西,但它不是一棵 git 工作樹(可能是別的 clone 留下的);` + + '請自己確認裡面沒有還沒保存的東西之後移除它', + ); + } +} diff --git a/test/worktree-remove.test.js b/test/worktree-remove.test.js new file mode 100644 index 0000000..45def73 --- /dev/null +++ b/test/worktree-remove.test.js @@ -0,0 +1,166 @@ +/** + * 手動清理工作樹。 + * + * 這一支的重點全在**守門**:清理會被 pr-watch 自動執行,而自動執行的東西只能做 + * 可逆的事。工作樹裡還有沒提交的東西就中止,絕不 `--force`——刪掉的檔案救不回來。 + * 移除只動工作樹,本機分支留著,使用者還能回頭看那段歷史。 + * + * 路徑由 `owner/repo/分支名` 推導,不查表也不讀狀態檔,所以這裡連帶驗它與 + * branch-prep 算出來的是同一條。 + * + * git 不做替身:在臨時 git repo 上跑真的 git,以本機裸 repo 充當遠端,不需網路。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { runScript, tmpRoot } from './helpers/run-script.js'; +import { makeTempRepoWithRemote } from './helpers/temp-repo.js'; + +const REPO = 'plugins/tea-sdlc'; + +/** + * 備好一棵真的工作樹:路徑由 branch-prep 建,測試不自己算, + * 兩支腳本推導出同一條路徑這件事本身就是要驗的東西。 + */ +async function withWorktree(t, slug = 'mine') { + const repo = makeTempRepoWithRemote(); + t.after(() => repo.cleanup()); + mkdirSync(tmpRoot, { recursive: true }); + const home = mkdtempSync(join(tmpRoot, 'home-')); + t.after(() => rmSync(home, { recursive: true, force: true })); + + const { json } = await runScript( + 'branch-prep.js', + ['--repo', REPO, '--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', slug], + { env: { TEA_SDLC_HOME: home } }, + ); + assert.equal(json.ok, true, json.error?.message); + return { repo, home, branch: json.data.branch, worktree: json.data.worktree }; +} + +const run = (home, args) => runScript('worktree-remove.js', args, { env: { TEA_SDLC_HOME: home } }); + +// ── 乾淨時移除 ───────────────────────────────────────────────────── + +test('工作樹乾淨時移除它,本機分支留著', async (t) => { + const { repo, home, branch, worktree } = await withWorktree(t); + + const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]); + + assert.equal(code, 0, json.error?.message); + assert.equal(json.data.removed, true); + assert.equal(existsSync(worktree), false, '工作樹要真的不見了'); + assert.equal( + repo.git('branch', '--list', branch).trim().replace(/^\*?\s*/, ''), + branch, + '本機分支要留著:不佔什麼空間,而使用者還會回頭看那段歷史', + ); +}); + +test('回報的路徑就是 branch-prep 建的那一條', async (t) => { + const { home, branch, worktree } = await withWorktree(t); + + const { json } = await run(home, ['--repo', REPO, '--branch', branch]); + + assert.equal(json.data.worktree, worktree, '兩支腳本要從同一個輸入推導出同一條路徑'); +}); + +// ── 守門:有未提交變更就中止 ─────────────────────────────────────── + +test('工作樹裡有未提交的變更時中止,並報出路徑', async (t) => { + const { home, branch, worktree } = await withWorktree(t); + writeFileSync(join(worktree, 'README.md'), '改到一半的東西\n'); + + const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]); + + assert.equal(code, 1); + assert.equal(json.error.code, 'WORKTREE_DIRTY'); + assert.match(json.error.message, new RegExp(worktree), '要指名是哪一條路徑,人才找得到它'); + assert.match(json.error.message, /README\.md/, '也要說出是哪些檔案擋住了'); + assert.equal(existsSync(worktree), true, '擋下來就不該已經刪掉'); + assert.equal(existsSync(join(worktree, 'README.md')), true); +}); + +test('未追蹤的檔案同樣算未提交:它一樣會被刪掉', async (t) => { + const { home, branch, worktree } = await withWorktree(t); + writeFileSync(join(worktree, 'notes.txt'), '還沒加進版控的筆記\n'); + + const { json } = await run(home, ['--repo', REPO, '--branch', branch]); + + assert.equal(json.error.code, 'WORKTREE_DIRTY'); + assert.match(json.error.message, /notes\.txt/); +}); + +// ── 冪等 ─────────────────────────────────────────────────────────── + +test('工作樹本來就不在時安靜地成功,重跑不會失敗', async (t) => { + const { home, branch } = await withWorktree(t); + await run(home, ['--repo', REPO, '--branch', branch]); + + const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]); + + assert.equal(code, 0, json.error?.message); + assert.equal(json.data.removed, false); + assert.equal(json.data.已經不在, true, '要說清楚是本來就不在,不是這次刪的'); +}); + +// ── --dry-run ───────────────────────────────────────────────────── + +test('--dry-run 印出將執行的 git 指令,且不移除任何東西', async (t) => { + const { home, branch, worktree } = await withWorktree(t); + + const { code, json } = await run(home, ['--repo', REPO, '--branch', branch, '--dry-run']); + + assert.equal(code, 0); + assert.equal(json.data.dryRun, true); + assert.deepEqual(json.data.commands, [`git worktree remove ${worktree}`]); + assert.equal( + json.data.commands.some((c) => c.includes('--force')), + false, + '絕不 --force:自動執行的東西只能做可逆的事', + ); + assert.equal(existsSync(worktree), true, '試跑不該真的刪掉'); +}); + +test('--dry-run 也照樣把守門的結果說出來', async (t) => { + // 試跑印得出漂亮的計畫、實跑卻被擋下來,是最難查的那種落差 + const { home, branch, worktree } = await withWorktree(t); + writeFileSync(join(worktree, 'wip.txt'), '做到一半\n'); + + const { json } = await run(home, ['--repo', REPO, '--branch', branch, '--dry-run']); + + assert.equal(json.error.code, 'WORKTREE_DIRTY'); +}); + +// ── flag ────────────────────────────────────────────────────────── + +test('缺 --branch 時指名缺的是哪一個', async (t) => { + const { home } = await withWorktree(t); + + const { json } = await run(home, ['--repo', REPO]); + + assert.equal(json.error.code, 'MISSING_FLAG'); + assert.match(json.error.message, /--branch/); +}); + +test('--repo 格式不是 owner/name 時失敗', async (t) => { + const { home } = await withWorktree(t); + + const { json } = await run(home, ['--repo', 'tea-sdlc', '--branch', 'feat/mine/main']); + + assert.equal(json.error.code, 'BAD_REPO'); +}); + +test('推導出來的路徑上是別人的東西時不碰它', async (t) => { + const { repo, home, branch, worktree } = await withWorktree(t); + repo.git('worktree', 'remove', worktree); + mkdirSync(worktree, { recursive: true }); + writeFileSync(join(worktree, '別人的東西.txt'), 'x\n'); + + const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]); + + assert.equal(code, 1); + assert.equal(json.error.code, 'NOT_A_WORKTREE'); + assert.equal(existsSync(join(worktree, '別人的東西.txt')), true); +}); -- 2.53.0 From d331bef1ed9f8edf3e7c06423862492f1511f9b5 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 16:59:30 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat(pr-watch):=20=E5=9B=9E=E5=A0=B1=20PR?= =?UTF-8?q?=20=E7=8F=BE=E6=B3=81=EF=BC=8C=E4=B8=A6=E5=9C=A8=E5=AE=83?= =?UTF-8?q?=E7=B5=90=E6=9D=9F=E6=99=82=E6=B8=85=E6=8E=89=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=A8=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 開發者要能隨時問一句「這個 PR 現在怎麼樣、我還有什麼要做」。回報 PR 狀態、還有幾則 留言沒處理、工作樹在哪、裡面有沒有沒提交的東西,以及固定列舉值的 suggestedAction ——用列舉值而不是一段文字,呼叫端才能程式化判斷。 一次性、無狀態:不做變化偵測。已處理的判定基準是 Gitea 上的 +1 與 resolve,那個狀態 不在本機記憶裡,所以一份現況快照就足以回答「還有沒有事要做」,不必跟上次比較,也就 不必留任何游標或狀態檔。不做常駐程序也不做 daemon,排程交給呼叫端。 只通知,不動手:偵測到未處理留言只給建議,不自動執行 /sdlc-fix——流程不該被模型自動 觸發,而 /sdlc-fix 要求「不確定時詢問使用者」,非互動模式下那個詢問無處可去。 四種狀態的處置不一致,所以表格驅動測:merged 與 closed 終止並清理;open 繼續監看; draft 繼續監看且**不清理**——被退回草稿代表還要繼續改,這時候那棵工作樹更需要留著。 議題 #41 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/pr-watch.js | 132 ++++++++++++++++++ test/pr-watch.test.js | 311 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 443 insertions(+) create mode 100644 scripts/pr-watch.js create mode 100644 test/pr-watch.test.js diff --git a/scripts/pr-watch.js b/scripts/pr-watch.js new file mode 100644 index 0000000..07e0619 --- /dev/null +++ b/scripts/pr-watch.js @@ -0,0 +1,132 @@ +#!/usr/bin/env node +/** + * 回報一顆 PR 的現況,並在它結束時清掉工作樹。 + * + * 一次性、無狀態、冪等:問一次答一次,**不做變化偵測**。「已處理」的判定基準是留言上 + * 自己打的 `+1` 與行內留言的 resolve,那個狀態已經存在 Gitea 上,所以一份現況快照就 + * 足以回答「還有沒有事要做」——不必跟上次的結果比較,也就不必在本機留任何游標或狀態檔。 + * + * **不是常駐程序也不是 daemon。** 「每隔多久跑一次」由呼叫端決定(cron、或 agent 工具 + * 自己的循環),本工具不長出排程器:daemon 要 pidfile,而本專案明定不在本機留狀態檔; + * 常駐前景程序雖然不留檔,卻把「監看中」綁在一個終端機 session 上。 + * + * **只通知,不動手。** 偵測到有未處理留言時只把建議動作放進輸出,不自動執行 `/sdlc-fix` + * ——流程不該被模型自動觸發,而 `/sdlc-fix` 要求「不確定時詢問使用者」,非互動模式下 + * 那個詢問無處可去,agent 只能自行決定,等於把一條驗收標準做成謊言。 + * + * 唯一會自動執行的副作用是清理工作樹,而且完全可逆(隨時能重建)。它只在 PR 已合併或 + * 已關閉時才發生;**被退回草稿時不清理**——那代表還要繼續改,這時候那棵工作樹更需要留著。 + * 有未提交變更就擋下並如實回報,絕不 `--force`。 + * + * 建議動作是**列舉值**而不是一段文字:呼叫端要能程式化判斷,而不是去解讀句子。 + * + * 用法: + * node scripts/pr-watch.js --repo owner/name --index 46 [--host <網址>] [--dry-run] + */ +import { + expectOk, + giteaRequest, + inspectWorktree, + main, + parseFlags, + parseIndex, + parseRepo, + preflight, + removeWorktree, + resolveLogin, + worktreePath, +} from './lib.js'; +import { fetchPull, readPullComments, unhandledCount } from './pr-threads.js'; + +main(async () => { + const flags = parseFlags(process.argv.slice(2), { + required: ['repo', 'index'], + optional: ['host'], + booleans: ['dry-run'], + }); + const repo = parseRepo(flags.repo); + const index = parseIndex(flags.index); + const dryRun = flags['dry-run'] === true; + + const login = resolveLogin({ host: flags.host }); + // 試跑照樣讀現況:這一支的輸出本來就是一份現況,手寫一份固定的清單等於什麼都沒回報 + if (!dryRun) await preflight(login, repo); + + const me = expectOk(await giteaRequest(login, 'GET', '/user'), 'GET /user').login; + const pull = await fetchPull(login, repo, index); + const state = stateOf(pull); + const 未處理留言數 = unhandledCount(await readPullComments(login, repo, index, me)); + + // 工作樹由 PR 自己的 head 分支推導,不必另外給——同一顆工作包算出來的永遠是同一條路徑 + const branch = pull.head?.ref ?? ''; + const worktree = worktreePath(repo, branch); + const terminal = state === 'merged' || state === 'closed'; + + // 終止狀態才清理。試跑只說要跑哪一行,不真的跑。 + const 清理 = terminal && !dryRun ? removeWorktree(worktree) : null; + const 工作樹 = 清理 ?? inspectWorktree(worktree); + const cleaned = 清理?.removed === true; + + const 報告 = { + repo, + index: pull.number, + title: pull.title, + url: pull.html_url, + branch, + state, + 未處理留言數, + 工作樹: { + 路徑: worktree, + // 清掉之後這兩個欄位講的是清理之前的狀況:cleaned 已經說了現在還在不在 + 存在: cleaned ? false : 工作樹.exists, + 有未提交變更: 工作樹.dirty, + 檔案: 工作樹.files, + }, + terminal, + cleaned, + suggestedAction: suggest({ terminal, cleaned, dryRun, 未處理留言數, 工作樹 }), + }; + + if (!dryRun) return 報告; + + return { + dryRun: true, + ...報告, + requests: [ + { method: 'GET', path: '/user' }, + { method: 'GET', path: `/repos/${repo}/pulls/${index}` }, + { method: 'GET', path: `/repos/${repo}/issues/${index}/comments` }, + { method: 'GET', path: `/repos/${repo}/pulls/${index}/reviews` }, + { method: 'GET', path: `/repos/${repo}/issues/${index}/timeline` }, + ], + // 讀取是冪等的,試跑照樣發;會改變東西的只有這一行,所以只有它被留到這裡 + commands: terminal && 工作樹.exists && !工作樹.dirty ? [`git worktree remove ${worktree}`] : [], + }; +}); + + +/** + * PR 的四種狀態。 + * Gitea 的 `state` 只有 open/closed,合併與草稿各是另一個布林值——三個欄位湊成一種 + * 狀態,而處置是看那一種,不是看 `state`:merged 與 closed 都終止,draft 則要繼續監看。 + */ +function stateOf(pull) { + if (pull.merged === true) return 'merged'; + if (pull.state === 'closed') return 'closed'; + return pull.draft === true ? 'draft' : 'open'; +} + +/** + * 下一步該做什麼,固定四個值。 + * + * 順序有意義:有東西擋住清理時要先講那件事,因為它需要人動手;未處理的留言其次; + * 都沒有就是真的沒事。試跑時「該清而還沒清」講的是 cleanup——它是這次不做的那件事。 + */ +function suggest({ terminal, cleaned, dryRun, 未處理留言數, 工作樹 }) { + if (terminal) { + if (工作樹.exists && 工作樹.dirty) return 'blocked-dirty'; + if (dryRun && 工作樹.exists && 工作樹.worktree) return 'cleanup'; + return cleaned || !工作樹.exists ? 'nothing-to-do' : 'cleanup'; + } + return 未處理留言數 > 0 ? 'run-sdlc-fix' : 'nothing-to-do'; +} diff --git a/test/pr-watch.test.js b/test/pr-watch.test.js new file mode 100644 index 0000000..0c78f72 --- /dev/null +++ b/test/pr-watch.test.js @@ -0,0 +1,311 @@ +/** + * 回報 PR 現況,並在它結束時清掉工作樹。 + * + * 這一支的價值在**四種 PR 狀態各自的處置**:merged 與 closed 是終止狀態,工作樹清掉; + * open 繼續監看;draft 尤其要盯住——被退回草稿代表還要繼續改,這時候那棵工作樹更需要 + * 留著,清掉它等於把人做到一半的環境收走。 + * + * 另外兩件事各有測試:建議動作是**列舉值**(呼叫端要程式化判斷,不是去讀一段文字), + * 以及清理的守門(有未提交變更就擋下,絕不 `--force`)。 + * + * 一次性、無狀態:不與上次的結果比較,也不讀寫任何游標或狀態檔——「已處理」的判定 + * 基準是 Gitea 上的 `+1` 與 resolve,現況快照本身就足以回答「還有沒有事要做」。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { runScript, tmpRoot } from './helpers/run-script.js'; +import { makeTempRepoWithRemote } from './helpers/temp-repo.js'; +import { healthyRoutes, stubEnv, withStubGitea } from './helpers/stub-gitea.js'; + +const REPO = 'plugins/tea-sdlc'; +const INDEX = 46; +const SLUG = 'pr-watch-and-cleanup'; +const BRANCH = `feat/${SLUG}/main`; + +/** + * 一顆 PR 的現場:狀態由 options 決定,留言預設沒有。 + * 一般留言只有在沒有自己打的 `+1` 時才算未處理。 + */ +function routes({ state = 'open', merged = false, draft = false, general = [] } = {}) { + const base = healthyRoutes(REPO, { + [`GET /api/v1/repos/${REPO}/pulls/${INDEX}`]: { + status: 200, + body: { + number: INDEX, + title: BRANCH, + html_url: `https://gitea.jsc.idv.tw/${REPO}/pulls/${INDEX}`, + state, + merged, + draft, + head: { ref: BRANCH }, + }, + }, + [`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: { + status: 200, + body: general.map((c, i) => ({ id: 100 + i, body: c.body, user: { login: 'reviewer' } })), + }, + [`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: { status: 200, body: [] }, + [`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: { status: 200, body: [] }, + }); + general.forEach((c, i) => { + base[`GET /api/v1/repos/${REPO}/issues/comments/${100 + i}/reactions`] = { + status: 200, + body: (c.reactions ?? []).map((content) => ({ + content, + user: { login: c.reactedBy ?? 'tester' }, + })), + }; + }); + return base; +} + +/** 備好一棵真的工作樹(由 branch-prep 建)與一台假 Gitea */ +async function withScene(t, pr = {}) { + const repo = makeTempRepoWithRemote(); + t.after(() => repo.cleanup()); + mkdirSync(tmpRoot, { recursive: true }); + const home = mkdtempSync(join(tmpRoot, 'home-')); + t.after(() => rmSync(home, { recursive: true, force: true })); + + const prep = await runScript( + 'branch-prep.js', + ['--repo', REPO, '--path', repo.dir, '--source', 'master', '--type', 'feat', '--slug', SLUG], + { env: { TEA_SDLC_HOME: home } }, + ); + assert.equal(prep.json.ok, true, prep.json.error?.message); + + const stub = await withStubGitea(t, routes(pr)); + const run = (args = []) => + runScript('pr-watch.js', ['--repo', REPO, '--index', String(INDEX), ...args], { + env: { ...stubEnv(stub), TEA_SDLC_HOME: home }, + }); + + return { repo, home, stub, run, worktree: prep.json.data.worktree }; +} + +// ── PR 狀態到處置的對照表 ───────────────────────────────────────── + +const DISPOSITION = [ + { + name: 'open:繼續監看,工作樹留著', + pr: { state: 'open' }, + expected: { state: 'open', terminal: false, cleaned: false, suggestedAction: 'nothing-to-do' }, + 工作樹還在: true, + }, + { + name: 'merged:終止,工作樹清掉', + pr: { state: 'closed', merged: true }, + expected: { state: 'merged', terminal: true, cleaned: true, suggestedAction: 'nothing-to-do' }, + 工作樹還在: false, + }, + { + name: 'closed:同樣終止,工作樹清掉', + pr: { state: 'closed', merged: false }, + expected: { state: 'closed', terminal: true, cleaned: true, suggestedAction: 'nothing-to-do' }, + 工作樹還在: false, + }, + { + name: 'draft:不終止也不清理——退回草稿代表還要繼續改', + pr: { state: 'open', draft: true }, + expected: { state: 'draft', terminal: false, cleaned: false, suggestedAction: 'nothing-to-do' }, + 工作樹還在: true, + }, +]; + +for (const { name, pr, expected, 工作樹還在 } of DISPOSITION) { + test(`狀態:${name}`, async (t) => { + const { run, worktree } = await withScene(t, pr); + + const { code, json } = await run(); + + assert.equal(code, 0, json.error?.message); + assert.equal(json.data.state, expected.state); + assert.equal(json.data.terminal, expected.terminal); + assert.equal(json.data.cleaned, expected.cleaned); + assert.equal(json.data.suggestedAction, expected.suggestedAction); + assert.equal(existsSync(worktree), 工作樹還在); + }); +} + +test('清理只移除工作樹,本機分支留著', async (t) => { + const { repo, run, worktree } = await withScene(t, { state: 'closed', merged: true }); + + await run(); + + assert.equal(existsSync(worktree), false); + assert.equal( + repo.git('branch', '--list', BRANCH).trim().replace(/^\*?\s*/, ''), + BRANCH, + '本機分支要留著,之後還能回頭看那段歷史', + ); +}); + +// ── 未處理留言 ───────────────────────────────────────────────────── + +test('有未處理留言時建議去跑 sdlc-fix,但不自己執行', async (t) => { + const { run, stub } = await withScene(t, { general: [{ body: '這裡少了錯誤處理' }] }); + + const { json } = await run(); + + assert.equal(json.data.未處理留言數, 1); + assert.equal(json.data.suggestedAction, 'run-sdlc-fix'); + assert.deepEqual( + stub.requests.filter((r) => r.method !== 'GET' && !r.path.endsWith('/issues/0')), + [], + '只通知不動手:監看不該替使用者回覆或標記任何東西', + ); +}); + +test('自己打過 +1 的留言算已處理,不再催', async (t) => { + const { run } = await withScene(t, { + general: [{ body: '這裡少了錯誤處理', reactions: ['+1'], reactedBy: 'tester' }], + }); + + const { json } = await run(); + + assert.equal(json.data.未處理留言數, 0); + assert.equal(json.data.suggestedAction, 'nothing-to-do'); +}); + +test('別人打的 +1 不算已處理:那是「我同意」,不是「我處理過了」', async (t) => { + const { run } = await withScene(t, { + general: [{ body: '這裡少了錯誤處理', reactions: ['+1'], reactedBy: 'reviewer' }], + }); + + const { json } = await run(); + + assert.equal(json.data.未處理留言數, 1); +}); + +test('draft 上也照樣數留言,繼續監看', async (t) => { + const { run, worktree } = await withScene(t, { + state: 'open', + draft: true, + general: [{ body: '這段先別急著合併' }], + }); + + const { json } = await run(); + + assert.equal(json.data.state, 'draft'); + assert.equal(json.data.suggestedAction, 'run-sdlc-fix'); + assert.equal(json.data.terminal, false); + assert.equal(existsSync(worktree), true, '被退回草稿時更需要那棵工作樹'); +}); + +// ── 清理的守門 ───────────────────────────────────────────────────── + +test('工作樹裡有未提交變更時擋下清理,並報出路徑', async (t) => { + const { run, worktree } = await withScene(t, { state: 'closed', merged: true }); + writeFileSync(join(worktree, 'wip.txt'), '做到一半\n'); + + const { code, json } = await run(); + + assert.equal(code, 0, '這是一份現況回報,擋下清理不等於整件事失敗'); + assert.equal(json.data.terminal, true); + assert.equal(json.data.cleaned, false); + assert.equal(json.data.suggestedAction, 'blocked-dirty'); + assert.equal(json.data.工作樹.有未提交變更, true); + assert.deepEqual(json.data.工作樹.檔案, ['wip.txt']); + assert.equal(existsSync(join(worktree, 'wip.txt')), true, '絕不 --force:沒提交的東西救不回來'); +}); + +test('工作樹早就不在時不當成失敗,也不說自己清了', async (t) => { + const { repo, run, worktree } = await withScene(t, { state: 'closed', merged: true }); + repo.git('worktree', 'remove', worktree); + + const { code, json } = await run(); + + assert.equal(code, 0); + assert.equal(json.data.工作樹.存在, false); + assert.equal(json.data.cleaned, false); + assert.equal(json.data.suggestedAction, 'nothing-to-do'); +}); + +// ── 回報內容 ─────────────────────────────────────────────────────── + +test('回報內容含 PR 狀態、未處理留言數與工作樹現況', async (t) => { + const { run, worktree } = await withScene(t, { general: [{ body: '一則意見' }] }); + + const { json } = await run(); + + assert.equal(json.data.index, INDEX); + assert.equal(json.data.url, `https://gitea.jsc.idv.tw/${REPO}/pulls/${INDEX}`); + assert.equal(json.data.branch, BRANCH, '工作樹是從 PR 的 head 分支推導的,要說出用的是哪一支'); + assert.equal(json.data.未處理留言數, 1); + assert.deepEqual(json.data.工作樹, { 路徑: worktree, 存在: true, 有未提交變更: false, 檔案: [] }); +}); + +test('建議動作是固定的列舉值,呼叫端才能程式化判斷', async (t) => { + const 列舉 = ['run-sdlc-fix', 'cleanup', 'nothing-to-do', 'blocked-dirty']; + const { run } = await withScene(t); + + const { json } = await run(); + + assert.ok(列舉.includes(json.data.suggestedAction), `不在列舉裡:${json.data.suggestedAction}`); +}); + +// ── 無狀態 ───────────────────────────────────────────────────────── + +test('跑兩次結果一樣,而且不留下任何游標或狀態檔', async (t) => { + const { repo, home, run } = await withScene(t, { general: [{ body: '一則意見' }] }); + + const first = await run(); + const second = await run(); + + assert.deepEqual(second.json, first.json, '不與上次比較,同樣的現況就該得到同樣的答案'); + assert.deepEqual(readdirSync(home), ['worktrees'], '家目錄底下只該有工作樹本身'); + assert.equal(repo.git('status', '--porcelain'), '', '目標專案裡不留任何東西'); +}); + +// ── --dry-run ───────────────────────────────────────────────────── + +test('--dry-run 印出將執行的 git 指令與將發出的請求,且不清理', async (t) => { + const { run, worktree } = await withScene(t, { state: 'closed', merged: true }); + + const { code, json } = await run(['--dry-run']); + + assert.equal(code, 0); + assert.equal(json.data.dryRun, true); + assert.deepEqual(json.data.commands, [`git worktree remove ${worktree}`]); + assert.ok(json.data.requests.some((r) => r.path.endsWith(`/pulls/${INDEX}`))); + assert.equal(json.data.cleaned, false); + assert.equal(json.data.suggestedAction, 'cleanup', '試跑不動手,該做的事要說出來'); + assert.equal(existsSync(worktree), true); +}); + +test('--dry-run 在還不該清理的狀態下不印 git 指令', async (t) => { + const { run } = await withScene(t, { state: 'open' }); + + const { json } = await run(['--dry-run']); + + assert.deepEqual(json.data.commands, []); +}); + +// ── 錯誤 ─────────────────────────────────────────────────────────── + +test('PR 不存在時回可區分的錯誤碼', async (t) => { + const { stub, run } = await withScene(t); + stub.requests.length = 0; + + const { json } = await runScript( + 'pr-watch.js', + ['--repo', REPO, '--index', '999'], + { env: stubEnv(stub) }, + ); + + assert.equal(json.error.code, 'PULL_NOT_FOUND'); +}); + +test('--index 不是正整數時擋在打 Gitea 之前', async (t) => { + const { stub } = await withScene(t); + const before = stub.requests.length; + + const { json } = await runScript('pr-watch.js', ['--repo', REPO, '--index', '0'], { + env: stubEnv(stub), + }); + + assert.equal(json.error.code, 'BAD_INDEX'); + assert.equal(stub.requests.length, before); +}); -- 2.53.0 From 32edd65962187c0e094f97aeab33a528cd66ad29 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 16:59:30 +0800 Subject: [PATCH 4/5] =?UTF-8?q?docs(sdlc-feat):=20=E7=AC=AC=E4=B8=89?= =?UTF-8?q?=E6=AE=B5=E6=94=B6=E5=B0=BE=E6=8C=87=E5=90=91=20pr-watch=20?= =?UTF-8?q?=E8=88=87=E6=89=8B=E5=8B=95=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 開出去之後流程就斷在那裡,使用者不會知道有東西可以查現況、也不會知道工作樹會被 自動清掉。收尾補一步,把兩支腳本講給使用者聽,並明講「多久跑一次由他自己排」。 邊界同時擋住兩件事:agent 不自己反覆跑 pr-watch,也不因為它建議了 run-sdlc-fix 就 自己去跑 /sdlc-fix——流程只由使用者明確叫用。 議題 #41 Co-Authored-By: Claude Opus 5 (1M context) --- prompts/sdlc-feat.md | 23 +++++++++++++++++++++++ test/sdlc-feat-assets.test.js | 13 +++++++++++++ 2 files changed, 36 insertions(+) diff --git a/prompts/sdlc-feat.md b/prompts/sdlc-feat.md index 5a20526..c870dee 100644 --- a/prompts/sdlc-feat.md +++ b/prompts/sdlc-feat.md @@ -306,6 +306,27 @@ repo**(錶停在那裡)。兩者常常不是同一個——議題在需求 - 碼錶是否已停;沒停的話把腳本回的那句說明一起帶出來 - 議題上還有沒有沒勾完的待辦(理論上應該沒有;有的話要說出來) +### 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 <分支名> +``` + ## 邊界 - 第一段**不改任何一行程式碼**、不勾待辦、不提交、不開 PR——那些是後面幾段的事。 @@ -321,6 +342,8 @@ repo**(錶停在那裡)。兩者常常不是同一個——議題在需求 - 不把實作規範或註解格式寫進目標專案的任何檔案。 - 不改與待辦無關的程式碼;順手想修的東西記下來說出來,不要摸進這次的變更裡。 - 不為了勾選在議題上留留言。 +- **不自動反覆執行 `pr-watch`**,也不因為它建議了 `run-sdlc-fix` 就自己去跑 `/sdlc-fix`—— + 流程只由使用者明確叫用。 - 不自行建立標籤。缺「進行中」標籤時中止並請使用者建立。 - 不代替使用者停錶,也不在被鎖擋下時繞過去。 - 不替使用者決定來源分支。 diff --git a/test/sdlc-feat-assets.test.js b/test/sdlc-feat-assets.test.js index 3df97a5..1908876 100644 --- a/test/sdlc-feat-assets.test.js +++ b/test/sdlc-feat-assets.test.js @@ -133,6 +133,19 @@ test('邊界把第一段不做的事分開列,且明講不寫本機狀態檔', assert.match(boundary, /換一台機器或換一個 agent/, '要說明為什麼不留狀態檔'); }); +test('第三段收尾時告訴使用者之後怎麼查 PR,但不自己反覆跑', () => { + assert.match(phase3, /pr-watch\.js/); + assert.match(phase3, /worktree-remove\.js/, '手動清理的出口也要講,否則沒人知道它在'); + assert.match(phase3, /不要自己反覆跑/); + assert.match(phase3, /suggestedAction/, '建議動作是列舉值,要讓使用者知道有這個東西可以判斷'); +}); + +test('邊界擋住「監看報了就自己去跑 sdlc-fix」', () => { + const boundary = prompt.slice(prompt.indexOf('## 邊界')); + assert.match(boundary, /不自動反覆執行 `pr-watch`/); + assert.match(boundary, /只由使用者明確叫用/); +}); + // ── 第二段:逐項實作 ─────────────────────────────────────────────── test('第二段明講改的是工作樹裡的檔案,不是主工作區', () => { -- 2.53.0 From 617a3eef725ffa43aeaa344bb767e9c0260216d2 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 17:06:07 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(pr-watch):=20=E8=A9=A6=E8=B7=91?= =?UTF-8?q?=E7=9A=84=E9=A0=90=E5=91=8A=E8=88=87=E5=AF=A6=E8=B7=91=E7=9A=84?= =?UTF-8?q?=E5=AE=88=E9=96=80=E5=B0=8D=E9=BD=8A=EF=BC=8C=E4=B8=A6=E8=AA=AA?= =?UTF-8?q?=E5=87=BA=E8=B7=AF=E5=BE=91=E8=A2=AB=E4=BD=94=E4=BD=8F=E9=80=99?= =?UTF-8?q?=E4=BB=B6=E4=BA=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review 抓到一處落差:試跑判「終止且工作樹乾淨」就預告 git worktree remove, 但實跑多判一件事——那條路徑上的東西是不是真的一棵工作樹。別的 clone 在同一條路徑上 留下目錄時(路徑由 owner/repo/分支名 推導,不含本機 clone 的位置),試跑會預告一行 實跑必然拒絕的指令,而回報裡完全看不出原因。 判斷收進 lib 的 inspectWorktree,由它直接給出 reason(missing/foreign/dirty/ removable):試跑與實跑、自動與手動四條路徑從此擋在同一個判斷上,worktree-remove 裡 那份重算的副本也跟著刪掉。回報多一個「是工作樹」欄位,手動出口的 NOT_A_WORKTREE 不再是使用者第一次聽到這件事。 順帶兩件同源的修正: - 「是不是工作樹」改認 .git 為**檔案**。獨立 clone 的 .git 是目錄,先前會被當成工作樹, 然後在 git worktree remove 那一步炸出一句原始錯誤。 - 兩支腳本的 --dry-run 請求預告收進 pr-threads 的 plannedRequests,並補上 pr-watch 先前漏掉的那句說明(逐則 reaction 與逐個 review 的行內留言事前列不完)。預告與實際 發出的請求分開寫,加一個端點就會有一邊忘了改。 - PR 讀不到 head 分支時給出 PULL_HEAD_MISSING,而不是拿空字串去推導一條路徑。 議題 #41 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/lib.js | 51 ++++++++++++++++++++++---------- scripts/pr-comments.js | 21 ++++++------- scripts/pr-threads.js | 23 +++++++++++++++ scripts/pr-watch.js | 57 +++++++++++++++++++++++------------- scripts/worktree-remove.js | 13 ++------ test/pr-watch.test.js | 35 +++++++++++++++++++++- test/worktree-remove.test.js | 12 ++++++++ 7 files changed, 152 insertions(+), 60 deletions(-) diff --git a/scripts/lib.js b/scripts/lib.js index 74d3964..ca2fb8b 100644 --- a/scripts/lib.js +++ b/scripts/lib.js @@ -13,7 +13,7 @@ */ import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { accessSync, constants, existsSync, readFileSync } from 'node:fs'; +import { accessSync, constants, existsSync, readFileSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -447,22 +447,22 @@ export function openGitRepo(path) { } /** - * 看一棵工作樹現在是什麼狀況。 + * 看一棵工作樹現在是什麼狀況,並直接說出「能不能清掉、不能的話卡在哪」。 * - * 清理前要先知道三件事:它還在不在、裡面有沒有沒提交的東西、以及那條路徑上的東西 - * 到底是不是一棵工作樹(別的 clone 也可能在同一條路徑上留下目錄——路徑由 - * owner/repo/分支名 推導,不含本機 clone 的位置)。 + * 判斷寫在這裡而不是各呼叫端:試跑與實跑、自動與手動都要擋在同一個地方, + * 兩份判斷遲早會分岔成「試跑說清得掉、實跑卻拒絕」。 + * + * 那條路徑上的東西不一定是工作樹:路徑由 owner/repo/分支名 推導,不含本機 clone 的 + * 位置,所以別的 clone 也可能在同一條路徑上留下東西。 * * @param {string} worktree 推導出的工作樹路徑 - * @returns {{path: string, exists: boolean, worktree: boolean, dirty: boolean, files: string[]}} + * @returns {{path: string, exists: boolean, isWorktree: boolean, dirty: boolean, + * files: string[], reason: 'missing'|'foreign'|'dirty'|'removable'}} */ export function inspectWorktree(worktree) { - const 空的 = { path: worktree, exists: false, worktree: false, dirty: false, files: [] }; - if (!existsSync(worktree)) return 空的; - // 工作樹的 .git 是一個檔案(指回主 repo),不是目錄;沒有它就不是 git 認得的工作樹 - if (!existsSync(join(worktree, '.git'))) { - return { ...空的, exists: true }; - } + const 空的 = { path: worktree, exists: false, isWorktree: false, dirty: false, files: [] }; + if (!existsSync(worktree)) return { ...空的, reason: 'missing' }; + if (!linkedWorktree(worktree)) return { ...空的, exists: true, reason: 'foreign' }; const files = runGit(['status', '--porcelain'], { cwd: worktree }) .split('\n') @@ -472,7 +472,27 @@ export function inspectWorktree(worktree) { // 切太多會讓檔名少一個字(README.md 變成 EADME.md),人照著去找會找不到。 .map((line) => line.replace(/^\s*\S{1,2}\s+/, '')); - return { path: worktree, exists: true, worktree: true, dirty: files.length > 0, files }; + return { + path: worktree, + exists: true, + isWorktree: true, + dirty: files.length > 0, + files, + reason: files.length > 0 ? 'dirty' : 'removable', + }; +} + +/** + * 這條路徑是不是一棵「連結出去的」工作樹。 + * 認的是 `.git` 為**檔案**(裡面一行 gitdir 指回主 repo)——獨立 clone 的 `.git` 是目錄, + * 對它下 `git worktree remove` 只會得到一句 git 的原始錯誤,而那不是使用者要的答案。 + */ +function linkedWorktree(worktree) { + try { + return statSync(join(worktree, '.git')).isFile(); + } catch { + return false; + } } /** @@ -487,12 +507,11 @@ export function inspectWorktree(worktree) { * * @param {string} worktree 推導出的工作樹路徑 * @returns {{removed: boolean, reason: 'removed'|'missing'|'dirty'|'foreign', files: string[], path: string}} + * reason 由 inspectWorktree 給,兩支腳本與試跑、實跑都擋在同一個判斷上 */ export function removeWorktree(worktree) { const state = inspectWorktree(worktree); - if (!state.exists) return { ...state, removed: false, reason: 'missing' }; - if (!state.worktree) return { ...state, removed: false, reason: 'foreign' }; - if (state.dirty) return { ...state, removed: false, reason: 'dirty' }; + if (state.reason !== 'removable') return { ...state, removed: false }; // 在工作樹自己裡面執行:它的 .git 指得回主 repo,呼叫端因此不必知道主 clone 在哪 runGit(['worktree', 'remove', worktree], { cwd: worktree }); diff --git a/scripts/pr-comments.js b/scripts/pr-comments.js index 53994bd..f327a58 100644 --- a/scripts/pr-comments.js +++ b/scripts/pr-comments.js @@ -21,7 +21,13 @@ import { preflight, resolveLogin, } from './lib.js'; -import { fetchPull, readPullComments, unhandledCount } from './pr-threads.js'; +import { + COMMENT_REQUEST_NOTE, + fetchPull, + plannedRequests, + readPullComments, + unhandledCount, +} from './pr-threads.js'; main(async () => { const flags = parseFlags(process.argv.slice(2), { @@ -31,23 +37,14 @@ main(async () => { }); const repo = parseRepo(flags.repo); const index = parseIndex(flags.index); - const pullPath = `/repos/${repo}/pulls/${index}`; if (flags['dry-run']) { return { dryRun: true, repo, index, - requests: [ - { method: 'GET', path: '/user' }, - { method: 'GET', path: pullPath }, - { method: 'GET', path: `/repos/${repo}/issues/${index}/comments` }, - { method: 'GET', path: `${pullPath}/reviews` }, - { method: 'GET', path: `/repos/${repo}/issues/${index}/timeline` }, - ], - note: - '每則一般留言還會各查一次 reaction、每個 review 還會各查一次它的行內留言;' + - '次數取決於留言數,事前無法列舉。', + requests: plannedRequests(repo, index), + note: COMMENT_REQUEST_NOTE, }; } diff --git a/scripts/pr-threads.js b/scripts/pr-threads.js index 2d4adb2..b7b96a6 100644 --- a/scripts/pr-threads.js +++ b/scripts/pr-threads.js @@ -38,6 +38,29 @@ export async function readPullComments(login, repo, index, me) { ]; } +/** + * 讀這三類留言會發出哪些請求。兩支腳本的 `--dry-run` 都印它——預告與實際發出的請求 + * 分開寫,加一個端點就會有一邊忘了改,而預告錯了等於沒有預告。 + * @returns {{method: string, path: string}[]} + */ +export function plannedRequests(repo, index) { + const pullPath = `/repos/${repo}/pulls/${index}`; + return [ + { method: 'GET', path: '/user' }, + { method: 'GET', path: pullPath }, + { method: 'GET', path: `/repos/${repo}/issues/${index}/comments` }, + { method: 'GET', path: `${pullPath}/reviews` }, + { method: 'GET', path: `/repos/${repo}/issues/${index}/timeline` }, + ]; +} + +/** + * `plannedRequests` 列不完的那部分。與 readPullComments 同進退——說明的是它發出的請求。 + */ +export const COMMENT_REQUEST_NOTE = + '每則一般留言還會各查一次 reaction、每個 review 還會各查一次它的行內留言;' + + '次數取決於留言數,事前無法列舉。'; + /** 還沒被處理的則數。`/sdlc-fix` 要做的量,也是 `pr-watch` 的建議動作的依據。 */ export function unhandledCount(留言) { return 留言.filter((comment) => !comment.已處理).length; diff --git a/scripts/pr-watch.js b/scripts/pr-watch.js index 07e0619..eba0f9d 100644 --- a/scripts/pr-watch.js +++ b/scripts/pr-watch.js @@ -24,6 +24,7 @@ * node scripts/pr-watch.js --repo owner/name --index 46 [--host <網址>] [--dry-run] */ import { + ScriptError, expectOk, giteaRequest, inspectWorktree, @@ -36,7 +37,13 @@ import { resolveLogin, worktreePath, } from './lib.js'; -import { fetchPull, readPullComments, unhandledCount } from './pr-threads.js'; +import { + COMMENT_REQUEST_NOTE, + fetchPull, + plannedRequests, + readPullComments, + unhandledCount, +} from './pr-threads.js'; main(async () => { const flags = parseFlags(process.argv.slice(2), { @@ -58,7 +65,14 @@ main(async () => { const 未處理留言數 = unhandledCount(await readPullComments(login, repo, index, me)); // 工作樹由 PR 自己的 head 分支推導,不必另外給——同一顆工作包算出來的永遠是同一條路徑 - const branch = pull.head?.ref ?? ''; + const branch = pull.head?.ref; + if (!branch) { + throw new ScriptError( + 'PULL_HEAD_MISSING', + `PR #${index} 讀不到 head 分支(來源分支可能已經被刪掉),推導不出工作樹在哪;` + + '請改用 worktree-remove --branch 指名要清哪一棵', + ); + } const worktree = worktreePath(repo, branch); const terminal = state === 'merged' || state === 'closed'; @@ -66,6 +80,8 @@ main(async () => { const 清理 = terminal && !dryRun ? removeWorktree(worktree) : null; const 工作樹 = 清理 ?? inspectWorktree(worktree); const cleaned = 清理?.removed === true; + // 試跑要預告的那一行,條件與實跑完全同一個:reason 由 lib 算,兩邊不各判一次 + const 清得掉 = 工作樹.reason === 'removable'; const 報告 = { repo, @@ -77,14 +93,17 @@ main(async () => { 未處理留言數, 工作樹: { 路徑: worktree, - // 清掉之後這兩個欄位講的是清理之前的狀況:cleaned 已經說了現在還在不在 + // 清掉之後這幾個欄位講的是清理之前的狀況:cleaned 已經說了現在還在不在 存在: cleaned ? false : 工作樹.exists, + // 路徑上有東西卻不是工作樹(多半是別的 clone 留下的)時,清理不會發生也不該 + // 靜靜跳過——手動出口會給出 NOT_A_WORKTREE,這個欄位是它的前情提要 + 是工作樹: 工作樹.isWorktree, 有未提交變更: 工作樹.dirty, 檔案: 工作樹.files, }, terminal, cleaned, - suggestedAction: suggest({ terminal, cleaned, dryRun, 未處理留言數, 工作樹 }), + suggestedAction: suggest({ terminal, cleaned, 清得掉, 未處理留言數, 工作樹 }), }; if (!dryRun) return 報告; @@ -92,15 +111,10 @@ main(async () => { return { dryRun: true, ...報告, - requests: [ - { method: 'GET', path: '/user' }, - { method: 'GET', path: `/repos/${repo}/pulls/${index}` }, - { method: 'GET', path: `/repos/${repo}/issues/${index}/comments` }, - { method: 'GET', path: `/repos/${repo}/pulls/${index}/reviews` }, - { method: 'GET', path: `/repos/${repo}/issues/${index}/timeline` }, - ], + requests: plannedRequests(repo, index), + note: COMMENT_REQUEST_NOTE, // 讀取是冪等的,試跑照樣發;會改變東西的只有這一行,所以只有它被留到這裡 - commands: terminal && 工作樹.exists && !工作樹.dirty ? [`git worktree remove ${worktree}`] : [], + commands: terminal && 清得掉 ? [`git worktree remove ${worktree}`] : [], }; }); @@ -119,14 +133,15 @@ function stateOf(pull) { /** * 下一步該做什麼,固定四個值。 * - * 順序有意義:有東西擋住清理時要先講那件事,因為它需要人動手;未處理的留言其次; - * 都沒有就是真的沒事。試跑時「該清而還沒清」講的是 cleanup——它是這次不做的那件事。 + * 終止的 PR 只問清理這件事:有沒提交的東西卡著就 blocked-dirty(要人自己處理), + * 清掉了或本來就不在就沒事了,其餘都還有一棵樹等著清——試跑不動手,路徑上是別的 + * clone 留下的東西也一樣,兩種都落在 cleanup,由手動出口給出確切的原因。 + * + * 還沒終止的 PR 只問留言:有沒處理完的就建議去跑 /sdlc-fix,但只是建議。 */ -function suggest({ terminal, cleaned, dryRun, 未處理留言數, 工作樹 }) { - if (terminal) { - if (工作樹.exists && 工作樹.dirty) return 'blocked-dirty'; - if (dryRun && 工作樹.exists && 工作樹.worktree) return 'cleanup'; - return cleaned || !工作樹.exists ? 'nothing-to-do' : 'cleanup'; - } - return 未處理留言數 > 0 ? 'run-sdlc-fix' : 'nothing-to-do'; +function suggest({ terminal, cleaned, 清得掉, 未處理留言數, 工作樹 }) { + if (!terminal) return 未處理留言數 > 0 ? 'run-sdlc-fix' : 'nothing-to-do'; + if (工作樹.reason === 'dirty') return 'blocked-dirty'; + if (cleaned || 工作樹.reason === 'missing') return 'nothing-to-do'; + return 清得掉 || 工作樹.reason === 'foreign' ? 'cleanup' : 'nothing-to-do'; } diff --git a/scripts/worktree-remove.js b/scripts/worktree-remove.js index cc2b59c..7d42ca5 100644 --- a/scripts/worktree-remove.js +++ b/scripts/worktree-remove.js @@ -39,14 +39,14 @@ main(async () => { // 試跑與實跑走同一條守門:試跑印得出漂亮的計畫、實跑卻被擋下來,是最難查的那種落差 if (flags['dry-run']) { const state = inspectWorktree(worktree); - checkRemovable({ ...state, reason: reasonOf(state) }, worktree); + checkRemovable(state, worktree); return { dryRun: true, repo, branch, worktree, - 已經不在: !state.exists, - commands: state.exists ? [`git worktree remove ${worktree}`] : [], + 已經不在: state.reason === 'missing', + commands: state.reason === 'removable' ? [`git worktree remove ${worktree}`] : [], }; } @@ -64,13 +64,6 @@ main(async () => { }); -/** inspectWorktree 的結果換算成 removeWorktree 用的同一組原因,讓試跑與實跑擋在同一處 */ -function reasonOf(state) { - if (!state.exists) return 'missing'; - if (!state.worktree) return 'foreign'; - return state.dirty ? 'dirty' : 'removed'; -} - /** 擋下來的兩種情況各有各的下一步,錯誤碼要分得開 */ function checkRemovable(result, worktree) { if (result.reason === 'dirty') { diff --git a/test/pr-watch.test.js b/test/pr-watch.test.js index 0c78f72..6832689 100644 --- a/test/pr-watch.test.js +++ b/test/pr-watch.test.js @@ -223,6 +223,33 @@ test('工作樹早就不在時不當成失敗,也不說自己清了', async (t assert.equal(json.data.suggestedAction, 'nothing-to-do'); }); +test('路徑上是別的 clone 留下的東西時,說出來而不是靜靜跳過', async (t) => { + const { repo, run, worktree } = await withScene(t, { state: 'closed', merged: true }); + repo.git('worktree', 'remove', worktree); + mkdirSync(join(worktree, '.git'), { recursive: true }); + + const { code, json } = await run(); + + assert.equal(code, 0); + assert.equal(json.data.工作樹.是工作樹, false, '.git 是目錄的是獨立 clone,不是工作樹'); + assert.equal(json.data.cleaned, false); + assert.equal(json.data.suggestedAction, 'cleanup', '要人動手,而手動出口會說出確切的原因'); + assert.equal(existsSync(join(worktree, '.git')), true, '不是我們建的東西就不碰'); +}); + +test('清不掉的路徑,--dry-run 不會預告一行實跑會拒絕的指令', async (t) => { + // 試跑印得出漂亮的計畫、實跑卻被擋下來,是最難查的那種落差 + const { repo, run, worktree } = await withScene(t, { state: 'closed', merged: true }); + repo.git('worktree', 'remove', worktree); + mkdirSync(worktree, { recursive: true }); + writeFileSync(join(worktree, '別人的東西.txt'), 'x\n'); + + const { json } = await run(['--dry-run']); + + assert.deepEqual(json.data.commands, []); + assert.equal(json.data.工作樹.是工作樹, false); +}); + // ── 回報內容 ─────────────────────────────────────────────────────── test('回報內容含 PR 狀態、未處理留言數與工作樹現況', async (t) => { @@ -234,7 +261,13 @@ test('回報內容含 PR 狀態、未處理留言數與工作樹現況', async ( assert.equal(json.data.url, `https://gitea.jsc.idv.tw/${REPO}/pulls/${INDEX}`); assert.equal(json.data.branch, BRANCH, '工作樹是從 PR 的 head 分支推導的,要說出用的是哪一支'); assert.equal(json.data.未處理留言數, 1); - assert.deepEqual(json.data.工作樹, { 路徑: worktree, 存在: true, 有未提交變更: false, 檔案: [] }); + assert.deepEqual(json.data.工作樹, { + 路徑: worktree, + 存在: true, + 是工作樹: true, + 有未提交變更: false, + 檔案: [], + }); }); test('建議動作是固定的列舉值,呼叫端才能程式化判斷', async (t) => { diff --git a/test/worktree-remove.test.js b/test/worktree-remove.test.js index 45def73..433f693 100644 --- a/test/worktree-remove.test.js +++ b/test/worktree-remove.test.js @@ -152,6 +152,18 @@ test('--repo 格式不是 owner/name 時失敗', async (t) => { assert.equal(json.error.code, 'BAD_REPO'); }); +test('路徑上是一個獨立的 clone 時也擋下:對它下 worktree remove 只會得到一句 git 的原始錯誤', async (t) => { + const { repo, home, branch, worktree } = await withWorktree(t); + repo.git('worktree', 'remove', worktree); + mkdirSync(join(worktree, '.git'), { recursive: true }); + + const { code, json } = await run(home, ['--repo', REPO, '--branch', branch]); + + assert.equal(code, 1); + assert.equal(json.error.code, 'NOT_A_WORKTREE'); + assert.equal(existsSync(join(worktree, '.git')), true); +}); + test('推導出來的路徑上是別人的東西時不碰它', async (t) => { const { repo, home, branch, worktree } = await withWorktree(t); repo.git('worktree', 'remove', worktree); -- 2.53.0