Files
tea-sdlc/scripts/pr-create.js
T
jiantw83andClaude Opus 5 ee474d0439 refactor(lib): 讀檔與列留言收進 lib,並讓已整併只認自己打的 +1
「讀一個 --xxx-file 或直接失敗」原本在四支腳本各寫一份,錯誤碼還有三種拼法
(BODY_FILE_NOT_FOUND/BODY_FILE_MISSING/CONTENT_FILE_NOT_FOUND)。同一種情況要有同一個
碼,呼叫端才分辨得出是哪一步壞了。留言分頁的那段咒語也是第三份,一併收成 listIssueComments。

countUnmergedComments 原本接受任何人的 +1,而讀留言那邊只認自己的——兩端對「已整併」的
定義不一致。後果是隊友對決策留言按個讚,未處理留言數就掉到 0,analyze 與 feat 再也不提示,
那則決策永遠不會被收進描述。統一成只認自己打的:別人按讚是「我同意」,不是「已經收進去了」。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 09:19:05 +00:00

231 lines
8.0 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 自己能重現的' +
'手動驗證步驟。「已測試通過」這種寫法看不出跑過什麼,等於沒寫',
);
}
}
/**
* 停錶。錶沒在跑時 Gitea 回 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;
// Gitea 對「這顆議題上沒有碼錶在跑」回的是 500。那不是失敗——
// 只有這一種 500 能這樣看待,訊息對不上就照常拋,免得把真的伺服器錯誤吞掉。
if (response.status === 500 && /stopwatch/i.test(response.body?.message ?? '')) {
return false;
}
expectOk(response, `POST ${path}`);
return false;
}