fix(pr-create): 錶停在議題的 repo,並讓重跑不會開出第二顆 PR

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 那一支拿掉。測試結果的空話檢查改成整段每一行都是空話才擋,段落也改用
行首標題切,描述裡引用到「## 測試結果」這幾個字不會再讓檢查看錯地方。
This commit is contained in:
2026-09-17 08:23:25 +00:00
parent 8a6145ea14
commit 30297cc49a
+83 -30
View File
@@ -13,9 +13,17 @@
* 停錶排在 PR 開出去之後,而且只在 PR 真的建立了才停:工時要記在真的有做事的那段 * 停錶排在 PR 開出去之後,而且只在 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 <描述檔> * node scripts/pr-create.js --repo owner/name --head <分支> --base <分支>
* [--base master] [--index 13] [--host <網址>] [--dry-run] * --body-file <描述檔> --index 13
* [--issue-repo owner/name] [--host <網址>] [--dry-run]
*/ */
import { existsSync, readFileSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';
import { import {
@@ -59,20 +67,26 @@ const EMPTY_TALK = new Set([
'全部通過', '全部通過',
'全數通過', '全數通過',
'皆通過', '皆通過',
'無異常',
'沒有問題',
'一切正常',
'正常',
'ok', 'ok',
'OK', 'OK',
]); ]);
main(async () => { main(async () => {
const flags = parseFlags(process.argv.slice(2), { const flags = parseFlags(process.argv.slice(2), {
required: ['repo', 'head', 'body-file'], required: ['repo', 'head', 'base', 'body-file', 'index'],
optional: ['base', 'index', 'host'], optional: ['issue-repo', 'host'],
booleans: ['dry-run'], booleans: ['dry-run'],
}); });
const repo = parseRepo(flags.repo); const repo = parseRepo(flags.repo);
// 議題預設與 PR 同一個 repo;跨 repo 的工作包要用 --issue-repo 指出來
const issueRepo = parseRepo(flags['issue-repo'] ?? flags.repo);
const head = flags.head; const head = flags.head;
const base = flags.base ?? 'master'; const base = flags.base;
const index = flags.index === undefined ? null : parseIndex(flags.index); const index = parseIndex(flags.index);
const body = readBody(flags['body-file']); const body = readBody(flags['body-file']);
// 描述先驗完再談寫入:不合格的描述不該等到實跑才發現 // 描述先驗完再談寫入:不合格的描述不該等到實跑才發現
@@ -80,42 +94,72 @@ main(async () => {
checkTestResult(body); checkTestResult(body);
const pullsPath = `/repos/${repo}/pulls`; 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 }; const payload = { title: head, head, base, body };
// 試跑也把登入解出來:沒跑過 tea login 的話,這一步就會說出來,不必等到實跑
const login = resolveLogin({ host: flags.host });
if (flags['dry-run']) { if (flags['dry-run']) {
const requests = [{ method: 'POST', path: pullsPath, body: payload }]; return {
if (stopPath) requests.push({ method: 'POST', path: stopPath, body: {} }); dryRun: true,
return { dryRun: true, repo, head, base, title: head, requests }; 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); 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 }), await giteaRequest(login, 'POST', pullsPath, { body: payload }),
`POST ${pullsPath}`, `POST ${pullsPath}`,
); );
// 錶只在 PR 真的開出去之後才停 // 錶只在 PR 確實存在之後才停。既有的 PR 也要停——那一步可能正是上次中斷的地方。
const stopped = stopPath === null ? false : await stopStopwatch(login, stopPath); const stopped = await stopStopwatch(login, stopPath);
return { return {
repo, repo,
issueRepo,
index, index,
created: existing === null,
title: pull.title, title: pull.title,
url: pull.html_url, url: pull.html_url,
number: pull.number, number: pull.number,
head, head,
base, base,
碼錶已停: stopped, 碼錶已停: stopped,
...(stopped || stopPath === null ...(stopped ? {} : { note: '碼錶本來就沒在這顆議題上運轉,PR 已經在了,這一步略過。' }),
? {}
: { 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) { function readBody(path) {
if (!existsSync(path)) { if (!existsSync(path)) {
throw new ScriptError('BODY_FILE_NOT_FOUND', `找不到描述檔 ${path}`); throw new ScriptError('BODY_FILE_NOT_FOUND', `找不到描述檔 ${path}`);
@@ -147,21 +191,25 @@ function checkSections(body) {
/** /**
* 「測試結果」不能是空話。 * 「測試結果」不能是空話。
* 判斷很窄——只看「整段只有一行,而那一行是已知的偷懶寫法」。窄是刻意的: * 判斷很窄——整段的每一行都是已知的偷懶寫法才算。窄是刻意的:
* 這一關要擋的是明顯沒跑過就交差,不是去評價別人的測試寫得夠不夠好。 * 這一關要擋的是明顯沒跑過就交差,不是去評價別人的測試寫得夠不夠好,
* 所以只要混進了一行真的輸出就放行。
*/ */
function checkTestResult(body) { function checkTestResult(body) {
const section = body.slice(body.indexOf('## 測試結果')); const lines = body.split('\n');
const content = section // 找行首的那個標題,而不是 indexOf:描述裡引用到「## 測試結果」這幾個字是常有的事
.split('\n') const start = lines.findIndex((line) => /^##\s+測試結果\s*$/.test(line));
.slice(1) const rest = lines.slice(start + 1);
.join('\n') const end = rest.findIndex((line) => /^##\s+/.test(line));
.trim(); const content = (end === -1 ? rest : rest.slice(0, end)).join('\n').trim();
const lines = content.split('\n').filter((line) => line.trim() !== ''); const written = content.split('\n').filter((line) => line.trim() !== '');
const onlyLine = lines.length === 1 ? lines[0].trim().replace(/[。..]$/, '') : null; // 每一行都是空話才算空話:混了實際輸出就放行,這一關不評價測試寫得好不好
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( throw new ScriptError(
'EMPTY_TEST_RESULT', 'EMPTY_TEST_RESULT',
'「測試結果」要放實際跑過的輸出;沒有自動化測試時,寫出 reviewer 自己能重現的' + '「測試結果」要放實際跑過的輸出;沒有自動化測試時,寫出 reviewer 自己能重現的' +
@@ -177,7 +225,12 @@ function checkTestResult(body) {
async function stopStopwatch(login, path) { async function stopStopwatch(login, path) {
const response = await giteaRequest(login, 'POST', path, { body: {} }); const response = await giteaRequest(login, 'POST', path, { body: {} });
if (response.status >= 200 && response.status < 300) return true; 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;
} }