Files
tea-sdlc/scripts/pr-create.js
T
jiantw83andClaude Opus 5 9ed719a4d7 fix(pr-create): 錶沒在跑時 409 與 500 都不算失敗
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>
2026-09-17 17:46:43 +08:00

239 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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;
}