feat/commit-split-and-pr/main #45
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* 開立 PR 並停錶。
|
||||
*
|
||||
* 三件事要驗:
|
||||
* 1. **標題等同分支名**——reviewer 在列表上看到的就是分支,兩者對不上會找錯 PR。
|
||||
* 2. **描述的七段都在**,而且「測試結果」不是空話。這一段是 reviewer 唯一能判斷
|
||||
* 「這東西真的跑過嗎」的依據,寫「已測試通過」等於沒寫。
|
||||
* 3. **PR 開完才停錶**,而且開失敗時錶不能停——工時要記在真的有做事的那段時間上。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { runScript, tmpRoot } from './helpers/run-script.js';
|
||||
import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
const HEAD = 'feat/commit-split-and-pr/main';
|
||||
const INDEX = 13;
|
||||
|
||||
/** 一份七段俱全的描述 */
|
||||
const BODY = `## 摘要
|
||||
|
||||
工作包做完之後,變更被整理成可讀的歷史,PR 開出來,碼錶停下。
|
||||
|
||||
## 需求議題
|
||||
|
||||
#1
|
||||
|
||||
## 工作包議題
|
||||
|
||||
#13
|
||||
|
||||
## 變更內容
|
||||
|
||||
新增 commit-split 與 pr-create 兩支腳本。
|
||||
|
||||
## 設計重點
|
||||
|
||||
分批的界線是類型,一個 commit 只裝一種。
|
||||
|
||||
## 解決的問題
|
||||
|
||||
巨大的單一 commit 等於沒有歷史。
|
||||
|
||||
## 影響的功能
|
||||
|
||||
sdlc-feat 的第三段。
|
||||
|
||||
## 測試結果
|
||||
|
||||
\`\`\`
|
||||
ℹ tests 527
|
||||
ℹ pass 527
|
||||
ℹ fail 0
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
/** 把描述寫成檔案,回傳路徑 */
|
||||
function bodyFile(name, content) {
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
const path = join(tmpRoot, `pr-body-${name}-${process.hrtime.bigint()}.md`);
|
||||
writeFileSync(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
function routes(overrides = {}) {
|
||||
return healthyRoutes(REPO, {
|
||||
[`POST /api/v1/repos/${REPO}/pulls`]: (req) => ({
|
||||
status: 201,
|
||||
body: { number: 99, title: req.body.title, html_url: `https://gitea.jsc.idv.tw/${REPO}/pulls/99` },
|
||||
}),
|
||||
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`]: { status: 201, body: {} },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
const withStub = (t, overrides = {}) => withStubGitea(t, routes(overrides));
|
||||
|
||||
const run = (args, stub) =>
|
||||
runScript('pr-create.js', ['--repo', REPO, '--head', HEAD, ...args], { env: envFor(stub) });
|
||||
|
||||
const posts = (stub) =>
|
||||
stub.requests.filter((r) => r.method === 'POST').map((r) => r.path);
|
||||
|
||||
// ── 標題與描述 ─────────────────────────────────────────────────────
|
||||
|
||||
test('PR 標題等同分支名', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = bodyFile('full', BODY);
|
||||
|
||||
const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
const pull = stub.requests.find((r) => r.path.endsWith('/pulls'));
|
||||
assert.equal(pull.body.title, HEAD);
|
||||
assert.equal(json.data.title, HEAD);
|
||||
});
|
||||
|
||||
test('描述原樣送出,一個字都不改寫', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = bodyFile('verbatim', BODY);
|
||||
|
||||
await run(['--body-file', file, '--index', String(INDEX)], stub);
|
||||
|
||||
const pull = stub.requests.find((r) => r.path.endsWith('/pulls'));
|
||||
assert.equal(pull.body.body, BODY);
|
||||
});
|
||||
|
||||
test('base 預設為 master,也可以指定', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = bodyFile('base', BODY);
|
||||
|
||||
await run(['--body-file', file, '--index', String(INDEX)], stub);
|
||||
const first = stub.requests.find((r) => r.path.endsWith('/pulls'));
|
||||
assert.equal(first.body.base, 'master');
|
||||
|
||||
const stub2 = await withStub(t);
|
||||
const file2 = bodyFile('base2', BODY);
|
||||
await run(['--body-file', file2, '--index', String(INDEX), '--base', 'develop'], stub2);
|
||||
assert.equal(stub2.requests.find((r) => r.path.endsWith('/pulls')).body.base, 'develop');
|
||||
});
|
||||
|
||||
// ── 七段:少一段就擋 ───────────────────────────────────────────────
|
||||
|
||||
const SECTIONS = [
|
||||
'摘要', '需求議題', '工作包議題', '變更內容',
|
||||
'設計重點', '解決的問題', '影響的功能', '測試結果',
|
||||
];
|
||||
|
||||
for (const missing of SECTIONS) {
|
||||
test(`描述缺少「${missing}」時擋下,並指名缺的是哪一段`, async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const body = BODY.split(/^## /m)
|
||||
.filter((part) => !part.startsWith(missing))
|
||||
.join('## ');
|
||||
const file = bodyFile(`missing-${missing}`, body);
|
||||
|
||||
const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'MISSING_SECTION');
|
||||
assert.match(json.error.message, new RegExp(missing));
|
||||
assert.deepEqual(posts(stub), [], '描述不合格就不該開 PR');
|
||||
});
|
||||
}
|
||||
|
||||
test('段落順序不對時也擋下:reviewer 每次要在同一個位置找到同一件事', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const swapped = BODY.replace(
|
||||
/## 設計重點([\s\S]*?)## 解決的問題([\s\S]*?)## 影響的功能/,
|
||||
'## 解決的問題$2## 設計重點$1## 影響的功能',
|
||||
);
|
||||
const file = bodyFile('order', swapped);
|
||||
|
||||
const { json } = await run(['--body-file', file, '--index', String(INDEX)], stub);
|
||||
|
||||
assert.equal(json.error.code, 'SECTION_ORDER');
|
||||
});
|
||||
|
||||
// ── 測試結果不能是空話 ─────────────────────────────────────────────
|
||||
|
||||
const EMPTY_TALK = ['已測試通過', '測試通過', '全部通過', '測試皆已通過', '無'];
|
||||
|
||||
for (const talk of EMPTY_TALK) {
|
||||
test(`測試結果只寫「${talk}」時擋下`, async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const body = BODY.replace(/## 測試結果[\s\S]*$/, `## 測試結果\n\n${talk}\n`);
|
||||
const file = bodyFile(`talk-${talk}`, body);
|
||||
|
||||
const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'EMPTY_TEST_RESULT');
|
||||
assert.match(json.error.message, /實際跑過|手動驗證/);
|
||||
assert.deepEqual(posts(stub), []);
|
||||
});
|
||||
}
|
||||
|
||||
test('測試結果是空的時候擋下', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = bodyFile('empty', BODY.replace(/## 測試結果[\s\S]*$/, '## 測試結果\n\n'));
|
||||
|
||||
const { json } = await run(['--body-file', file, '--index', String(INDEX)], stub);
|
||||
|
||||
assert.equal(json.error.code, 'EMPTY_TEST_RESULT');
|
||||
});
|
||||
|
||||
test('沒有自動化測試時,寫得出可重現的手動驗證步驟就放行', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const manual = BODY.replace(
|
||||
/## 測試結果[\s\S]*$/,
|
||||
'## 測試結果\n\n本工作包無自動化測試,手動驗證步驟:\n\n'
|
||||
+ '1. 執行 `node scripts/pr-create.js --dry-run`\n'
|
||||
+ '2. 確認印出的標題等於分支名\n',
|
||||
);
|
||||
const file = bodyFile('manual', manual);
|
||||
|
||||
const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
});
|
||||
|
||||
// ── 停錶 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('PR 開完之後才停錶,順序不能反', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = bodyFile('stop', BODY);
|
||||
|
||||
await run(['--body-file', file, '--index', String(INDEX)], stub);
|
||||
|
||||
assert.deepEqual(posts(stub), [
|
||||
`/api/v1/repos/${REPO}/pulls`,
|
||||
`/api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`,
|
||||
]);
|
||||
});
|
||||
|
||||
test('PR 開失敗時不停錶:工時要記在真的有做事的那段時間上', async (t) => {
|
||||
const stub = await withStub(t, {
|
||||
[`POST /api/v1/repos/${REPO}/pulls`]: { status: 422, body: { message: 'pull request already exists' } },
|
||||
});
|
||||
const file = bodyFile('fail', BODY);
|
||||
|
||||
const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(
|
||||
posts(stub).some((path) => path.endsWith('/stopwatch/stop')),
|
||||
false,
|
||||
'PR 沒開成就不該停錶',
|
||||
);
|
||||
assert.match(json.error.message, /422|already exists/);
|
||||
});
|
||||
|
||||
test('錶本來就沒在跑時不算失敗:PR 已經開出去了', async (t) => {
|
||||
// Gitea 對「沒有碼錶在跑」回 500;這時 PR 已經建立,不該把整件事報成失敗
|
||||
const stub = await withStub(t, {
|
||||
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`]: {
|
||||
status: 500,
|
||||
body: { message: 'cannot stop a non existent stopwatch' },
|
||||
},
|
||||
});
|
||||
const file = bodyFile('nowatch', BODY);
|
||||
|
||||
const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
assert.equal(json.data.碼錶已停, false);
|
||||
assert.match(json.data.note ?? '', /碼錶/);
|
||||
});
|
||||
|
||||
test('沒給 --index 就不停錶,也不報錯', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = bodyFile('noindex', BODY);
|
||||
|
||||
const { code, json } = await run(['--body-file', file], stub);
|
||||
|
||||
assert.equal(code, 0, JSON.stringify(json));
|
||||
assert.deepEqual(posts(stub), [`/api/v1/repos/${REPO}/pulls`]);
|
||||
assert.equal(json.data.碼錶已停, false);
|
||||
});
|
||||
|
||||
// ── 輸入 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('描述檔不存在時回可區分的錯誤碼', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
|
||||
const { json } = await run(['--body-file', join(tmpRoot, '不存在的檔案.md')], stub);
|
||||
|
||||
assert.equal(json.error.code, 'BODY_FILE_NOT_FOUND');
|
||||
});
|
||||
|
||||
// ── --dry-run ─────────────────────────────────────────────────────
|
||||
|
||||
test('--dry-run 印出將建立的 PR 與將停的錶,但不碰 Gitea', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = bodyFile('dry', BODY);
|
||||
|
||||
const { code, json } = await run(['--body-file', file, '--index', String(INDEX), '--dry-run'], stub);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.dryRun, true);
|
||||
assert.deepEqual(
|
||||
json.data.requests.map((r) => `${r.method} ${r.path}`),
|
||||
[
|
||||
`POST /repos/${REPO}/pulls`,
|
||||
`POST /repos/${REPO}/issues/${INDEX}/stopwatch/stop`,
|
||||
],
|
||||
);
|
||||
assert.equal(json.data.requests[0].body.title, HEAD, '試跑要看得到標題長什麼樣');
|
||||
assert.equal(stub.requests.length, 0);
|
||||
});
|
||||
|
||||
test('--dry-run 照樣驗描述:不合格的描述不該等到實跑才發現', async (t) => {
|
||||
const stub = await withStub(t);
|
||||
const file = bodyFile('dry-bad', BODY.replace('## 測試結果', '## 測試結論'));
|
||||
|
||||
const { json } = await run(['--body-file', file, '--dry-run'], stub);
|
||||
|
||||
assert.equal(json.error.code, 'MISSING_SECTION');
|
||||
});
|
||||
Reference in New Issue
Block a user