Gitea 回「cannot stop a non-existent stopwatch」時用的狀態碼隨站台而異:這台回 409, 而腳本只認 500。結果是 PR 已經開出去了,卻以 exit 1 與 HTTP_ERROR 收場——照它自己 寫下的理由,那會讓人以為 PR 沒開成而重跑一次。三次重現(議題 #41、#50、#42)。 認的是「狀態碼在 409/500 這一組 **且** 訊息說的是碼錶」:只看訊息會把真的伺服器錯誤 一起吞掉,只看狀態碼會把別的衝突也當成沒錶。兩種狀態碼各一條測試,另加一條 「訊息對不上的 409 照常拋出」。 README 與 AGENTS.md 的「六個流程正本尚未到齊」也一併改掉——六份都在了,那句話會讓 使用者以為裝了也沒指令可用,在 AGENTS.md 裡還會誤導下一個 agent。並補一條測試把說法 與 prompts/ 的實際份數釘在一起,免得下次又走鐘。 議題 #54 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
239 lines
8.1 KiB
JavaScript
239 lines
8.1 KiB
JavaScript
#!/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,
|
||
} from './lib.js';
|
||
|
||
/** 描述的固定段落,順序即 reviewer 閱讀的順序 */
|
||
const SECTIONS = [
|
||
'摘要',
|
||
'需求議題',
|
||
'工作包議題',
|
||
'變更內容',
|
||
'設計重點',
|
||
'解決的問題',
|
||
'影響的功能',
|
||
'測試結果',
|
||
];
|
||
|
||
/**
|
||
* 「測試結果」裡等於沒寫的那幾句。
|
||
* 不是窮舉,是擋住最常見的偷懶寫法——真的跑過的話,貼輸出比打這幾個字還快。
|
||
*/
|
||
const EMPTY_TALK = new Set([
|
||
'無',
|
||
'沒有',
|
||
'N/A',
|
||
'n/a',
|
||
'已測試',
|
||
'已測試通過',
|
||
'測試通過',
|
||
'測試皆通過',
|
||
'測試皆已通過',
|
||
'全部通過',
|
||
'全數通過',
|
||
'皆通過',
|
||
'無異常',
|
||
'沒有問題',
|
||
'一切正常',
|
||
'正常',
|
||
'ok',
|
||
'OK',
|
||
]);
|
||
|
||
main(async () => {
|
||
const flags = parseFlags(process.argv.slice(2), {
|
||
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;
|
||
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 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: {} },
|
||
],
|
||
};
|
||
}
|
||
|
||
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, stopPath);
|
||
|
||
return {
|
||
repo,
|
||
issueRepo,
|
||
index,
|
||
created: existing === null,
|
||
title: pull.title,
|
||
url: pull.html_url,
|
||
number: pull.number,
|
||
head,
|
||
base,
|
||
碼錶已停: stopped,
|
||
...(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;
|
||
}
|
||
|
||
|
||
|
||
/** 八個段落一個都不能少,而且順序要與 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 自己能重現的' +
|
||
'手動驗證步驟。「已測試通過」這種寫法看不出跑過什麼,等於沒寫',
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 「這顆議題上沒有碼錶在跑」的回法不只一種:看過 500,也看過 409。
|
||
* 狀態碼隨站台版本而異,所以認的是「狀態碼在這一組裡 **且** 訊息說的是碼錶」——
|
||
* 只看訊息會把真的伺服器錯誤一起吞掉,只看狀態碼會把別的衝突也當成沒錶。
|
||
*/
|
||
const NO_STOPWATCH_STATUS = [409, 500];
|
||
|
||
/**
|
||
* 停錶。錶沒在跑時不算失敗——PR 已經開出去了,把整件事報成失敗只會讓人以為
|
||
* PR 沒開成而重跑一次。
|
||
*/
|
||
async function stopStopwatch(login, path) {
|
||
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;
|
||
}
|