feat(pr-create): 開立 PR 並停錶
標題等同分支名:reviewer 在列表上看到的就是分支,兩者對不上會找錯 PR。 描述的段落固定且順序固定,缺一段或順序不對就擋下,不自動補——補出來的段落是編的, 而 reviewer 會把它當成真的。 「測試結果」另外驗一次它不是空話。那一段是 reviewer 唯一能判斷「這東西真的跑過嗎」的 依據,寫「已測試通過」等於沒寫。判斷刻意很窄,只擋「整段只有一行,而那一行是已知的 偷懶寫法」——這一關要擋的是明顯沒跑過就交差,不是去評價別人的測試寫得夠不夠好。 沒有自動化測試時,寫得出可重現的手動驗證步驟就放行。 停錶排在 PR 開出去之後,而且只在 PR 真的建立了才停:工時要記在真的有做事的那段時間上。 錶本來就沒在跑不算失敗(Gitea 對此回 500)——PR 已經開出去了,把整件事報成失敗只會讓人 以為 PR 沒開成而重跑一次。
This commit is contained in:
@@ -0,0 +1,183 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* 開立 PR,然後停錶。
|
||||||
|
*
|
||||||
|
* 標題等同分支名:reviewer 在列表上看到的就是分支,兩者對不上會找錯 PR。
|
||||||
|
*
|
||||||
|
* 描述的段落固定且順序固定——reviewer 每次都在同一個位置找到要找的資訊。缺一段或順序
|
||||||
|
* 不對就擋下,不自動補:補出來的段落是編的,而 reviewer 會把它當成真的。
|
||||||
|
*
|
||||||
|
* 「測試結果」另外驗一次它不是空話。那一段是 reviewer 唯一能判斷「這東西真的跑過嗎」
|
||||||
|
* 的依據,寫「已測試通過」等於沒寫。沒有自動化測試時,寫可重現的手動驗證步驟也算數。
|
||||||
|
*
|
||||||
|
* 停錶排在 PR 開出去之後,而且只在 PR 真的建立了才停:工時要記在真的有做事的那段
|
||||||
|
* 時間上。錶本來就沒在跑不算失敗——PR 已經開出去了,不該把整件事報成失敗。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node scripts/pr-create.js --repo owner/name --head <分支> --body-file <描述檔>
|
||||||
|
* [--base master] [--index 13] [--host <網址>] [--dry-run]
|
||||||
|
*/
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import {
|
||||||
|
ScriptError,
|
||||||
|
expectOk,
|
||||||
|
giteaRequest,
|
||||||
|
main,
|
||||||
|
parseFlags,
|
||||||
|
parseIndex,
|
||||||
|
parseRepo,
|
||||||
|
preflight,
|
||||||
|
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', 'body-file'],
|
||||||
|
optional: ['base', 'index', 'host'],
|
||||||
|
booleans: ['dry-run'],
|
||||||
|
});
|
||||||
|
const repo = parseRepo(flags.repo);
|
||||||
|
const head = flags.head;
|
||||||
|
const base = flags.base ?? 'master';
|
||||||
|
const index = flags.index === undefined ? null : parseIndex(flags.index);
|
||||||
|
const body = readBody(flags['body-file']);
|
||||||
|
|
||||||
|
// 描述先驗完再談寫入:不合格的描述不該等到實跑才發現
|
||||||
|
checkSections(body);
|
||||||
|
checkTestResult(body);
|
||||||
|
|
||||||
|
const pullsPath = `/repos/${repo}/pulls`;
|
||||||
|
const stopPath = index === null ? null : `/repos/${repo}/issues/${index}/stopwatch/stop`;
|
||||||
|
const payload = { title: head, head, base, body };
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = resolveLogin({ host: flags.host });
|
||||||
|
await preflight(login, repo);
|
||||||
|
|
||||||
|
const pull = expectOk(
|
||||||
|
await giteaRequest(login, 'POST', pullsPath, { body: payload }),
|
||||||
|
`POST ${pullsPath}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 錶只在 PR 真的開出去之後才停
|
||||||
|
const stopped = stopPath === null ? false : await stopStopwatch(login, stopPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
repo,
|
||||||
|
index,
|
||||||
|
title: pull.title,
|
||||||
|
url: pull.html_url,
|
||||||
|
number: pull.number,
|
||||||
|
head,
|
||||||
|
base,
|
||||||
|
碼錶已停: stopped,
|
||||||
|
...(stopped || stopPath === null
|
||||||
|
? {}
|
||||||
|
: { note: '碼錶本來就沒在這顆議題上運轉,PR 已經開出去了,這一步略過。' }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
function readBody(path) {
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
throw new ScriptError('BODY_FILE_NOT_FOUND', `找不到描述檔 ${path}`);
|
||||||
|
}
|
||||||
|
return readFileSync(path, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 八個段落一個都不能少,而且順序要與 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 section = body.slice(body.indexOf('## 測試結果'));
|
||||||
|
const content = section
|
||||||
|
.split('\n')
|
||||||
|
.slice(1)
|
||||||
|
.join('\n')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const lines = content.split('\n').filter((line) => line.trim() !== '');
|
||||||
|
const onlyLine = lines.length === 1 ? lines[0].trim().replace(/[。..]$/, '') : null;
|
||||||
|
|
||||||
|
if (content === '' || (onlyLine !== null && EMPTY_TALK.has(onlyLine))) {
|
||||||
|
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;
|
||||||
|
if (response.status === 500 || response.status === 409) return false;
|
||||||
|
|
||||||
|
return expectOk(response, `POST ${path}`) !== undefined;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user