From 695694b3fd2b5b12b740ebdffd0e9b2cb6deceaa Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 17:16:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(pr-watch):=20=E4=BE=86=E6=BA=90=E5=88=86?= =?UTF-8?q?=E6=94=AF=E8=A2=AB=E5=88=AA=E6=8E=89=E4=B9=8B=E5=BE=8C=EF=BC=8C?= =?UTF-8?q?=E6=94=B9=E5=BE=9E=20head.label=20=E5=8F=96=E5=88=86=E6=94=AF?= =?UTF-8?q?=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 合併而來源分支被刪掉之後,Gitea 把 head.ref 換成 refs/pull/{編號}/head,分支名 退到 head.label。pr-watch 用的是 head.ref,於是推導出一條根本不存在的工作樹路徑, 回報「工作樹不在、沒事要做」——而合併正是唯一該清理工作樹的時機,等於自動清理在真實 情況下從來不會成立,而且靜悄悄的,沒有人會發現。 實測 #48(合併後)推導出 bb4f5127f641,真正的那一棵在 bd457620184e。 fork 來的 PR 的 label 是 owner:branch,只取分支那一段。兩邊都取不到時明確中止 (PULL_HEAD_MISSING),並指出手動出口怎麼指名那一棵,不拿空字串去推導路徑。 測試先前沒抓到,是因為假的 PR 一律照「開著的 PR」寫,head.ref 永遠是分支名。 補上「已合併且來源分支已刪」與 fork 兩種 head 的樣子。 議題 #50 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/pr-watch.js | 30 ++++++++++++++++++++---- test/pr-watch.test.js | 54 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/scripts/pr-watch.js b/scripts/pr-watch.js index eba0f9d..f9b721b 100644 --- a/scripts/pr-watch.js +++ b/scripts/pr-watch.js @@ -64,13 +64,13 @@ main(async () => { const state = stateOf(pull); const 未處理留言數 = unhandledCount(await readPullComments(login, repo, index, me)); - // 工作樹由 PR 自己的 head 分支推導,不必另外給——同一顆工作包算出來的永遠是同一條路徑 - const branch = pull.head?.ref; - if (!branch) { + // 工作樹由 PR 自己的來源分支推導,不必另外給——同一顆工作包算出來的永遠是同一條路徑 + const branch = headBranch(pull); + if (branch === '') { throw new ScriptError( 'PULL_HEAD_MISSING', - `PR #${index} 讀不到 head 分支(來源分支可能已經被刪掉),推導不出工作樹在哪;` + - '請改用 worktree-remove --branch 指名要清哪一棵', + `PR #${index} 讀不到來源分支名,推導不出工作樹在哪;` + + '請改用 worktree-remove --repo --branch <分支名> 指名要清哪一棵', ); } const worktree = worktreePath(repo, branch); @@ -119,6 +119,26 @@ main(async () => { }); +/** + * PR 的來源分支名。 + * + * **不能只看 `head.ref`。** PR 合併而來源分支被刪掉之後,Gitea 會把 `head.ref` 換成 + * `refs/pull/{編號}/head`,分支名退到 `head.label`——而合併正是唯一該清理工作樹的時機。 + * 拿那個 ref 去推導會算出一條根本不存在的路徑,然後回報「工作樹不在、沒事要做」: + * 自動清理於是在真實情況下從來不會成立,而且靜悄悄的,沒有人會發現。 + * + * fork 來的 PR 的 label 是 `owner:branch`,只取分支那一段——工作樹是以分支名推導的。 + */ +function headBranch(pull) { + const label = (pull.head?.label ?? '').trim(); + const 分支 = label.includes(':') ? label.slice(label.indexOf(':') + 1) : label; + if (分支 !== '') return 分支; + + // label 缺席時才退回 ref,而且 refs/pull/… 不是分支名,寧可說讀不到 + const ref = (pull.head?.ref ?? '').trim(); + return /^refs\/pull\//.test(ref) ? '' : ref; +} + /** * PR 的四種狀態。 * Gitea 的 `state` 只有 open/closed,合併與草稿各是另一個布林值——三個欄位湊成一種 diff --git a/test/pr-watch.test.js b/test/pr-watch.test.js index 6832689..dfa9af3 100644 --- a/test/pr-watch.test.js +++ b/test/pr-watch.test.js @@ -28,7 +28,7 @@ const BRANCH = `feat/${SLUG}/main`; * 一顆 PR 的現場:狀態由 options 決定,留言預設沒有。 * 一般留言只有在沒有自己打的 `+1` 時才算未處理。 */ -function routes({ state = 'open', merged = false, draft = false, general = [] } = {}) { +function routes({ state = 'open', merged = false, draft = false, general = [], head } = {}) { const base = healthyRoutes(REPO, { [`GET /api/v1/repos/${REPO}/pulls/${INDEX}`]: { status: 200, @@ -39,7 +39,9 @@ function routes({ state = 'open', merged = false, draft = false, general = [] } state, merged, draft, - head: { ref: BRANCH }, + // 預設是「PR 還開著」的樣子:head.ref 就是分支名。合併之後 Gitea 會換一種樣子, + // 那一種由測試自己指定。 + head: head ?? { ref: BRANCH, label: BRANCH }, }, }, [`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: { @@ -250,6 +252,54 @@ test('清不掉的路徑,--dry-run 不會預告一行實跑會拒絕的指令' assert.equal(json.data.工作樹.是工作樹, false); }); +// ── 合併之後的 head ─────────────────────────────────────────────── + +test('來源分支在合併時被刪掉,仍要推導出正確的工作樹並清掉它', async (t) => { + // Gitea 在這種情況下把 head.ref 換成 refs/pull/{編號}/head,分支名退到 head.label。 + // 拿 ref 去推導會算出一條不存在的路徑,然後靜靜回報「沒事要做」——而合併正是唯一 + // 該動手清理的時機,等於自動清理在真實情況下從來不會成立。 + const { run, worktree } = await withScene(t, { + state: 'closed', + merged: true, + head: { ref: `refs/pull/${INDEX}/head`, label: BRANCH }, + }); + + const { code, json } = await run(); + + assert.equal(code, 0, json.error?.message); + assert.equal(json.data.branch, BRANCH, '分支名要取自 head.label'); + assert.equal(json.data.工作樹.路徑, worktree); + assert.equal(json.data.cleaned, true); + assert.equal(existsSync(worktree), false); +}); + +test('fork 來的 PR:head.label 是 owner:branch,只取分支那一段', async (t) => { + const { run, worktree } = await withScene(t, { + state: 'closed', + merged: true, + head: { ref: `refs/pull/${INDEX}/head`, label: `someone:${BRANCH}` }, + }); + + const { json } = await run(); + + assert.equal(json.data.branch, BRANCH); + assert.equal(existsSync(worktree), false, '推導出來的仍是同一條路徑'); +}); + +test('分支名兩邊都取不到時明確中止,不拿空字串去推導路徑', async (t) => { + const { run } = await withScene(t, { + state: 'closed', + merged: true, + head: { ref: `refs/pull/${INDEX}/head` }, + }); + + const { code, json } = await run(); + + assert.equal(code, 1); + assert.equal(json.error.code, 'PULL_HEAD_MISSING'); + assert.match(json.error.message, /worktree-remove/, '要指出手動出口怎麼指名那一棵'); +}); + // ── 回報內容 ─────────────────────────────────────────────────────── test('回報內容含 PR 狀態、未處理留言數與工作樹現況', async (t) => {