From 30297cc49a8cf8b25993d735fda8485926d9706f Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 08:23:25 +0000 Subject: [PATCH] =?UTF-8?q?fix(pr-create):=20=E9=8C=B6=E5=81=9C=E5=9C=A8?= =?UTF-8?q?=E8=AD=B0=E9=A1=8C=E7=9A=84=20repo=EF=BC=8C=E4=B8=A6=E8=AE=93?= =?UTF-8?q?=E9=87=8D=E8=B7=91=E4=B8=8D=E6=9C=83=E9=96=8B=E5=87=BA=E7=AC=AC?= =?UTF-8?q?=E4=BA=8C=E9=A1=86=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claim 在工作包議題上起錶,而 PR 開在目標專案上——議題在需求的 repo,程式碼在 repos 列的 那幾個,兩者常常不是同一個。先前用同一個 --repo 同時指 PR 與停錶,停到的會是別人的議題 (或 404 而在 PR 已建立之後才拋錯),而自己的錶還在跑。新增 --issue-repo,預設與 --repo 相同。 --index 與 --base 改為必填:停錶是這一步的一部分,忘了給會讓工時算不準;而目標專案的開發 分支可能叫 master、main 或 develop,猜錯會開到不存在的 base。 重跑先查同一個 head 有沒有開著的 PR,有就回傳它並把 created 設為 false,然後照樣停錶—— 那一步可能正是上次中斷的地方。先前重跑會撞上 Gitea 的 422,而那個錯誤看不出 PR 其實已經開好。 停錶的 500 改為只在訊息確實提到 stopwatch 時才視為「本來就沒在跑」,免得把真的伺服器錯誤 吞掉;未經證實的 409 那一支拿掉。測試結果的空話檢查改成整段每一行都是空話才擋,段落也改用 行首標題切,描述裡引用到「## 測試結果」這幾個字不會再讓檢查看錯地方。 --- scripts/pr-create.js | 113 +++++++++++++++++++++++++++++++------------ 1 file changed, 83 insertions(+), 30 deletions(-) diff --git a/scripts/pr-create.js b/scripts/pr-create.js index 25de2f0..c256f1b 100644 --- a/scripts/pr-create.js +++ b/scripts/pr-create.js @@ -13,9 +13,17 @@ * 停錶排在 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 <分支> --body-file <描述檔> - * [--base master] [--index 13] [--host <網址>] [--dry-run] + * node scripts/pr-create.js --repo owner/name --head <分支> --base <分支> + * --body-file <描述檔> --index 13 + * [--issue-repo owner/name] [--host <網址>] [--dry-run] */ import { existsSync, readFileSync } from 'node:fs'; import { @@ -59,20 +67,26 @@ const EMPTY_TALK = new Set([ '全部通過', '全數通過', '皆通過', + '無異常', + '沒有問題', + '一切正常', + '正常', 'ok', 'OK', ]); main(async () => { const flags = parseFlags(process.argv.slice(2), { - required: ['repo', 'head', 'body-file'], - optional: ['base', 'index', 'host'], + required: ['repo', 'head', 'base', 'body-file', 'index'], + optional: ['issue-repo', 'host'], 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 ?? 'master'; - const index = flags.index === undefined ? null : parseIndex(flags.index); + const base = flags.base; + const index = parseIndex(flags.index); const body = readBody(flags['body-file']); // 描述先驗完再談寫入:不合格的描述不該等到實跑才發現 @@ -80,42 +94,72 @@ main(async () => { checkTestResult(body); const pullsPath = `/repos/${repo}/pulls`; - const stopPath = index === null ? null : `/repos/${repo}/issues/${index}/stopwatch/stop`; + const stopPath = `/repos/${issueRepo}/issues/${index}/stopwatch/stop`; const payload = { title: head, head, base, body }; + // 試跑也把登入解出來:沒跑過 tea login 的話,這一步就會說出來,不必等到實跑 + const login = resolveLogin({ host: flags.host }); + if (flags['dry-run']) { - const requests = [{ method: 'POST', path: pullsPath, body: payload }]; - if (stopPath) requests.push({ method: 'POST', path: stopPath, body: {} }); - return { dryRun: true, repo, head, base, title: head, requests }; + return { + dryRun: true, + repo, + issueRepo, + index, + head, + base, + title: head, + requests: [ + { method: 'POST', path: pullsPath, body: payload }, + { method: 'POST', path: stopPath, body: {} }, + ], + }; } - const login = resolveLogin({ host: flags.host }); await preflight(login, repo); - const pull = expectOk( + // 冪等:同一個 head 已經有開著的 PR 就用它,重跑不會開出第二顆 + const existing = await findOpenPull(login, repo, head); + const pull = existing ?? expectOk( await giteaRequest(login, 'POST', pullsPath, { body: payload }), `POST ${pullsPath}`, ); - // 錶只在 PR 真的開出去之後才停 - const stopped = stopPath === null ? false : await stopStopwatch(login, stopPath); + // 錶只在 PR 確實存在之後才停。既有的 PR 也要停——那一步可能正是上次中斷的地方。 + const stopped = await stopStopwatch(login, stopPath); return { repo, + issueRepo, index, + created: existing === null, title: pull.title, url: pull.html_url, number: pull.number, head, base, 碼錶已停: stopped, - ...(stopped || stopPath === null - ? {} - : { note: '碼錶本來就沒在這顆議題上運轉,PR 已經開出去了,這一步略過。' }), + ...(stopped ? {} : { note: '碼錶本來就沒在這顆議題上運轉,PR 已經在了,這一步略過。' }), }; }); +/** + * 找同一個 head 上開著的 PR。 + * 重跑時 Gitea 會對重複的 PR 回 422,而那個錯誤看不出「其實已經開好了」—— + * 先查一次,重跑就是安靜地接上。 + */ +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; +} + + function readBody(path) { if (!existsSync(path)) { throw new ScriptError('BODY_FILE_NOT_FOUND', `找不到描述檔 ${path}`); @@ -147,21 +191,25 @@ function checkSections(body) { /** * 「測試結果」不能是空話。 - * 判斷很窄——只看「整段只有一行,而那一行是已知的偷懶寫法」。窄是刻意的: - * 這一關要擋的是明顯沒跑過就交差,不是去評價別人的測試寫得夠不夠好。 + * 判斷很窄——整段的每一行都是已知的偷懶寫法才算。窄是刻意的: + * 這一關要擋的是明顯沒跑過就交差,不是去評價別人的測試寫得夠不夠好, + * 所以只要混進了一行真的輸出就放行。 */ function checkTestResult(body) { - const section = body.slice(body.indexOf('## 測試結果')); - const content = section - .split('\n') - .slice(1) - .join('\n') - .trim(); + 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 lines = content.split('\n').filter((line) => line.trim() !== ''); - const onlyLine = lines.length === 1 ? lines[0].trim().replace(/[。..]$/, '') : null; + 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 === '' || (onlyLine !== null && EMPTY_TALK.has(onlyLine))) { + if (content === '' || allEmptyTalk) { throw new ScriptError( 'EMPTY_TEST_RESULT', '「測試結果」要放實際跑過的輸出;沒有自動化測試時,寫出 reviewer 自己能重現的' + @@ -177,7 +225,12 @@ function checkTestResult(body) { async function stopStopwatch(login, path) { const response = await giteaRequest(login, 'POST', path, { body: {} }); if (response.status >= 200 && response.status < 300) return true; - if (response.status === 500 || response.status === 409) return false; - return expectOk(response, `POST ${path}`) !== undefined; + // Gitea 對「這顆議題上沒有碼錶在跑」回的是 500。那不是失敗—— + // 只有這一種 500 能這樣看待,訊息對不上就照常拋,免得把真的伺服器錯誤吞掉。 + if (response.status === 500 && /stopwatch/i.test(response.body?.message ?? '')) { + return false; + } + expectOk(response, `POST ${path}`); + return false; }