test(pr-create): 錶停在議題的 repo,並讓重跑不會開出第二顆 PR

claim 在工作包議題上起錶,而 PR 開在目標專案上——議題在需求的 repo,程式碼在 repos 列的
那幾個,兩者常常不是同一個。先前用同一個 --repo 同時指 PR 與停錶,停到的會是別人的議題
(或 404 而在 PR 已建立之後才拋錯),而自己的錶還在跑。新增 --issue-repo,預設與 --repo 相同。

--index 與 --base 改為必填:停錶是這一步的一部分,忘了給會讓工時算不準;而目標專案的開發
分支可能叫 master、main 或 develop,猜錯會開到不存在的 base。

重跑先查同一個 head 有沒有開著的 PR,有就回傳它並把 created 設為 false,然後照樣停錶——
那一步可能正是上次中斷的地方。先前重跑會撞上 Gitea 的 422,而那個錯誤看不出 PR 其實已經開好。

停錶的 500 改為只在訊息確實提到 stopwatch 時才視為「本來就沒在跑」,免得把真的伺服器錯誤
吞掉;未經證實的 409 那一支拿掉。測試結果的空話檢查改成整段每一行都是空話才擋,段落也改用
行首標題切,描述裡引用到「## 測試結果」這幾個字不會再讓檢查看錯地方。
This commit is contained in:
2026-09-17 08:23:25 +00:00
parent 30297cc49a
commit fbc80f286f
+157 -33
View File
@@ -14,7 +14,10 @@ import { join } from 'node:path';
import { runScript, tmpRoot } from './helpers/run-script.js'; import { runScript, tmpRoot } from './helpers/run-script.js';
import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js'; import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js';
const REPO = 'plugins/tea-sdlc'; /** PR 開在目標專案上 */
const REPO = 'myorg/myapp';
/** 工作包議題在另一個 repo 上——這是常態,不是特例 */
const ISSUE_REPO = 'plugins/tea-sdlc';
const HEAD = 'feat/commit-split-and-pr/main'; const HEAD = 'feat/commit-split-and-pr/main';
const INDEX = 13; const INDEX = 13;
@@ -66,19 +69,26 @@ function bodyFile(name, content) {
function routes(overrides = {}) { function routes(overrides = {}) {
return healthyRoutes(REPO, { return healthyRoutes(REPO, {
[`GET /api/v1/repos/${REPO}/pulls`]: { status: 200, body: [] },
[`POST /api/v1/repos/${REPO}/pulls`]: (req) => ({ [`POST /api/v1/repos/${REPO}/pulls`]: (req) => ({
status: 201, status: 201,
body: { number: 99, title: req.body.title, html_url: `https://gitea.jsc.idv.tw/${REPO}/pulls/99` }, 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: {} }, [`POST /api/v1/repos/${ISSUE_REPO}/issues/${INDEX}/stopwatch/stop`]: { status: 201, body: {} },
...overrides, ...overrides,
}); });
} }
const withStub = (t, overrides = {}) => withStubGitea(t, routes(overrides)); const withStub = (t, overrides = {}) => withStubGitea(t, routes(overrides));
const BASE_ARGS = ['--repo', REPO, '--head', HEAD, '--base', 'master'];
const run = (args, stub) => const run = (args, stub) =>
runScript('pr-create.js', ['--repo', REPO, '--head', HEAD, ...args], { env: envFor(stub) }); runScript('pr-create.js', [...BASE_ARGS, ...args], { env: envFor(stub) });
/** 完整的一次呼叫:議題在另一個 repo 上 */
const runFull = (file, stub, extra = []) =>
run(['--body-file', file, '--issue-repo', ISSUE_REPO, '--index', String(INDEX), ...extra], stub);
const posts = (stub) => const posts = (stub) =>
stub.requests.filter((r) => r.method === 'POST').map((r) => r.path); stub.requests.filter((r) => r.method === 'POST').map((r) => r.path);
@@ -89,10 +99,10 @@ test('PR 標題等同分支名', async (t) => {
const stub = await withStub(t); const stub = await withStub(t);
const file = bodyFile('full', BODY); const file = bodyFile('full', BODY);
const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub); const { code, json } = await runFull(file, stub);
assert.equal(code, 0, JSON.stringify(json)); assert.equal(code, 0, JSON.stringify(json));
const pull = stub.requests.find((r) => r.path.endsWith('/pulls')); const pull = stub.requests.find((r) => r.method === 'POST' && r.path.endsWith('/pulls'));
assert.equal(pull.body.title, HEAD); assert.equal(pull.body.title, HEAD);
assert.equal(json.data.title, HEAD); assert.equal(json.data.title, HEAD);
}); });
@@ -101,24 +111,33 @@ test('描述原樣送出,一個字都不改寫', async (t) => {
const stub = await withStub(t); const stub = await withStub(t);
const file = bodyFile('verbatim', BODY); const file = bodyFile('verbatim', BODY);
await run(['--body-file', file, '--index', String(INDEX)], stub); await runFull(file, stub);
const pull = stub.requests.find((r) => r.path.endsWith('/pulls')); const pull = stub.requests.find((r) => r.method === 'POST' && r.path.endsWith('/pulls'));
assert.equal(pull.body.body, BODY); assert.equal(pull.body.body, BODY);
}); });
test('base 預設為 master,也可以指定', async (t) => { test('--base 照給的值送出,不預設猜一個', async (t) => {
// 目標專案的開發分支可能叫 master、main 或 develop,猜錯會開到不存在的 base
const stub = await withStub(t); const stub = await withStub(t);
const file = bodyFile('base', BODY); const file = bodyFile('base', BODY);
await run(['--body-file', file, '--index', String(INDEX)], stub); await runFull(file, stub);
const first = stub.requests.find((r) => r.path.endsWith('/pulls'));
assert.equal(first.body.base, 'master');
const stub2 = await withStub(t); assert.equal(stub.requests.find((r) => r.method === 'POST').body.base, 'master');
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'); test('沒給 --base 時擋下,並說明為什麼不替你猜', async (t) => {
const stub = await withStub(t);
const file = bodyFile('nobase', BODY);
const { json } = await runScript('pr-create.js', [
'--repo', REPO, '--head', HEAD, '--body-file', file,
'--issue-repo', ISSUE_REPO, '--index', String(INDEX),
], { env: envFor(stub) });
assert.equal(json.error.code, 'MISSING_FLAG');
assert.match(json.error.message, /--base/);
}); });
// ── 七段:少一段就擋 ─────────────────────────────────────────────── // ── 七段:少一段就擋 ───────────────────────────────────────────────
@@ -136,7 +155,7 @@ for (const missing of SECTIONS) {
.join('## '); .join('## ');
const file = bodyFile(`missing-${missing}`, body); const file = bodyFile(`missing-${missing}`, body);
const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub); const { code, json } = await runFull(file, stub);
assert.equal(code, 1); assert.equal(code, 1);
assert.equal(json.error.code, 'MISSING_SECTION'); assert.equal(json.error.code, 'MISSING_SECTION');
@@ -153,11 +172,34 @@ test('段落順序不對時也擋下:reviewer 每次要在同一個位置找
); );
const file = bodyFile('order', swapped); const file = bodyFile('order', swapped);
const { json } = await run(['--body-file', file, '--index', String(INDEX)], stub); const { json } = await runFull(file, stub);
assert.equal(json.error.code, 'SECTION_ORDER'); assert.equal(json.error.code, 'SECTION_ORDER');
}); });
test('測試結果整段都是空話時擋下,不只看單行', async (t) => {
const stub = await withStub(t);
const body = BODY.replace(/## 測試結果[\s\S]*$/, '## 測試結果\n\n已測試通過\n無異常\n');
const file = bodyFile('multi-talk', body);
const { json } = await runFull(file, stub);
assert.equal(json.error.code, 'EMPTY_TEST_RESULT');
});
test('描述裡引用到「## 測試結果」這幾個字時,檢查的仍是真正那一段', async (t) => {
const stub = await withStub(t);
const body = BODY.replace(
'新增 commit-split 與 pr-create 兩支腳本。',
'新增兩支腳本,並要求 `## 測試結果` 這一段放實際輸出。',
);
const file = bodyFile('quoted-heading', body);
const { code, json } = await runFull(file, stub);
assert.equal(code, 0, JSON.stringify(json));
});
// ── 測試結果不能是空話 ───────────────────────────────────────────── // ── 測試結果不能是空話 ─────────────────────────────────────────────
const EMPTY_TALK = ['已測試通過', '測試通過', '全部通過', '測試皆已通過', '無']; const EMPTY_TALK = ['已測試通過', '測試通過', '全部通過', '測試皆已通過', '無'];
@@ -168,7 +210,7 @@ for (const talk of EMPTY_TALK) {
const body = BODY.replace(/## 測試結果[\s\S]*$/, `## 測試結果\n\n${talk}\n`); const body = BODY.replace(/## 測試結果[\s\S]*$/, `## 測試結果\n\n${talk}\n`);
const file = bodyFile(`talk-${talk}`, body); const file = bodyFile(`talk-${talk}`, body);
const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub); const { code, json } = await runFull(file, stub);
assert.equal(code, 1); assert.equal(code, 1);
assert.equal(json.error.code, 'EMPTY_TEST_RESULT'); assert.equal(json.error.code, 'EMPTY_TEST_RESULT');
@@ -181,7 +223,7 @@ test('測試結果是空的時候擋下', async (t) => {
const stub = await withStub(t); const stub = await withStub(t);
const file = bodyFile('empty', BODY.replace(/## 測試結果[\s\S]*$/, '## 測試結果\n\n')); const file = bodyFile('empty', BODY.replace(/## 測試結果[\s\S]*$/, '## 測試結果\n\n'));
const { json } = await run(['--body-file', file, '--index', String(INDEX)], stub); const { json } = await runFull(file, stub);
assert.equal(json.error.code, 'EMPTY_TEST_RESULT'); assert.equal(json.error.code, 'EMPTY_TEST_RESULT');
}); });
@@ -196,7 +238,7 @@ test('沒有自動化測試時,寫得出可重現的手動驗證步驟就放
); );
const file = bodyFile('manual', manual); const file = bodyFile('manual', manual);
const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub); const { code, json } = await runFull(file, stub);
assert.equal(code, 0, JSON.stringify(json)); assert.equal(code, 0, JSON.stringify(json));
}); });
@@ -207,21 +249,50 @@ test('PR 開完之後才停錶,順序不能反', async (t) => {
const stub = await withStub(t); const stub = await withStub(t);
const file = bodyFile('stop', BODY); const file = bodyFile('stop', BODY);
await run(['--body-file', file, '--index', String(INDEX)], stub); await runFull(file, stub);
assert.deepEqual(posts(stub), [ assert.deepEqual(posts(stub), [
`/api/v1/repos/${REPO}/pulls`, `/api/v1/repos/${REPO}/pulls`,
`/api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`, `/api/v1/repos/${ISSUE_REPO}/issues/${INDEX}/stopwatch/stop`,
]); ]);
}); });
test('錶停在議題所在的 repo,不是 PR 所在的 repo', async (t) => {
// claim 在工作包議題上起錶,而 PR 開在目標專案上——兩者常常不是同一個 repo。
// 拿 PR 的 repo 去停錶,停到的是別人的議題,而自己的錶還在跑。
const stub = await withStub(t);
const file = bodyFile('two-repos', BODY);
const { code, json } = await runFull(file, stub);
assert.equal(code, 0, JSON.stringify(json));
assert.equal(json.data.碼錶已停, true);
assert.equal(
posts(stub).some((path) => path.startsWith(`/api/v1/repos/${REPO}/issues/`)),
false,
'不該對 PR 的那個 repo 發停錶請求',
);
});
test('沒給 --issue-repo 時,議題就在 PR 的同一個 repo 上', async (t) => {
const stub = await withStubGitea(t, routes({
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`]: { status: 201, body: {} },
}));
const file = bodyFile('same-repo', BODY);
const { code } = await run(['--body-file', file, '--index', String(INDEX)], stub);
assert.equal(code, 0);
assert.ok(posts(stub).includes(`/api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`));
});
test('PR 開失敗時不停錶:工時要記在真的有做事的那段時間上', async (t) => { test('PR 開失敗時不停錶:工時要記在真的有做事的那段時間上', async (t) => {
const stub = await withStub(t, { const stub = await withStub(t, {
[`POST /api/v1/repos/${REPO}/pulls`]: { status: 422, body: { message: 'pull request already exists' } }, [`POST /api/v1/repos/${REPO}/pulls`]: { status: 422, body: { message: 'pull request already exists' } },
}); });
const file = bodyFile('fail', BODY); const file = bodyFile('fail', BODY);
const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub); const { code, json } = await runFull(file, stub);
assert.equal(code, 1); assert.equal(code, 1);
assert.equal( assert.equal(
@@ -235,29 +306,79 @@ test('PR 開失敗時不停錶:工時要記在真的有做事的那段時間
test('錶本來就沒在跑時不算失敗:PR 已經開出去了', async (t) => { test('錶本來就沒在跑時不算失敗:PR 已經開出去了', async (t) => {
// Gitea 對「沒有碼錶在跑」回 500;這時 PR 已經建立,不該把整件事報成失敗 // Gitea 對「沒有碼錶在跑」回 500;這時 PR 已經建立,不該把整件事報成失敗
const stub = await withStub(t, { const stub = await withStub(t, {
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`]: { [`POST /api/v1/repos/${ISSUE_REPO}/issues/${INDEX}/stopwatch/stop`]: {
status: 500, status: 500,
body: { message: 'cannot stop a non existent stopwatch' }, body: { message: 'cannot stop a non existent stopwatch' },
}, },
}); });
const file = bodyFile('nowatch', BODY); const file = bodyFile('nowatch', BODY);
const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub); const { code, json } = await runFull(file, stub);
assert.equal(code, 0, JSON.stringify(json)); assert.equal(code, 0, JSON.stringify(json));
assert.equal(json.data.碼錶已停, false); assert.equal(json.data.碼錶已停, false);
assert.match(json.data.note ?? '', /碼錶/); assert.match(json.data.note ?? '', /碼錶/);
}); });
test('沒給 --index 就不停錶,也不報錯', async (t) => { test('沒給 --index 時擋下:停錶是這一步的一部分,忘了給會讓工時算不準', async (t) => {
const stub = await withStub(t); const stub = await withStub(t);
const file = bodyFile('noindex', BODY); const file = bodyFile('noindex', BODY);
const { code, json } = await run(['--body-file', file], stub); const { json } = await run(['--body-file', file], stub);
assert.equal(json.error.code, 'MISSING_FLAG');
assert.match(json.error.message, /--index/);
});
// ── 冪等:重跑不會開出第二顆 PR ───────────────────────────────────
test('同一個 head 已經有開著的 PR 時回傳既有那一顆,不再開一顆', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/pulls`]: {
status: 200,
body: [{ number: 7, title: HEAD, html_url: 'https://example.com/7', head: { ref: HEAD } }],
},
});
const file = bodyFile('dup', BODY);
const { code, json } = await runFull(file, stub);
assert.equal(code, 0, JSON.stringify(json)); assert.equal(code, 0, JSON.stringify(json));
assert.deepEqual(posts(stub), [`/api/v1/repos/${REPO}/pulls`]); assert.equal(json.data.created, false);
assert.equal(json.data.碼錶已停, false); assert.equal(json.data.number, 7);
assert.equal(
posts(stub).some((path) => path.endsWith('/pulls')),
false,
'既有的那一顆就是答案,不要再開一顆',
);
});
test('已經有 PR 時照樣停錶:那一步可能是上次中斷的地方', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/pulls`]: {
status: 200,
body: [{ number: 7, title: HEAD, html_url: 'https://example.com/7', head: { ref: HEAD } }],
},
});
const file = bodyFile('dup-stop', BODY);
const { json } = await runFull(file, stub);
assert.equal(json.data.碼錶已停, true);
});
test('別的分支的 PR 不算數', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/pulls`]: {
status: 200,
body: [{ number: 7, title: '別的', html_url: 'https://example.com/7', head: { ref: 'feat/別的/main' } }],
},
});
const file = bodyFile('other-branch', BODY);
const { json } = await runFull(file, stub);
assert.equal(json.data.created, true);
}); });
// ── 輸入 ─────────────────────────────────────────────────────────── // ── 輸入 ───────────────────────────────────────────────────────────
@@ -265,7 +386,10 @@ test('沒給 --index 就不停錶,也不報錯', async (t) => {
test('描述檔不存在時回可區分的錯誤碼', async (t) => { test('描述檔不存在時回可區分的錯誤碼', async (t) => {
const stub = await withStub(t); const stub = await withStub(t);
const { json } = await run(['--body-file', join(tmpRoot, '不存在的檔案.md')], stub); const { json } = await run(
['--body-file', join(tmpRoot, '不存在的檔案.md'), '--index', String(INDEX)],
stub,
);
assert.equal(json.error.code, 'BODY_FILE_NOT_FOUND'); assert.equal(json.error.code, 'BODY_FILE_NOT_FOUND');
}); });
@@ -276,7 +400,7 @@ test('--dry-run 印出將建立的 PR 與將停的錶,但不碰 Gitea', async
const stub = await withStub(t); const stub = await withStub(t);
const file = bodyFile('dry', BODY); const file = bodyFile('dry', BODY);
const { code, json } = await run(['--body-file', file, '--index', String(INDEX), '--dry-run'], stub); const { code, json } = await runFull(file, stub, ['--dry-run']);
assert.equal(code, 0); assert.equal(code, 0);
assert.equal(json.data.dryRun, true); assert.equal(json.data.dryRun, true);
@@ -284,7 +408,7 @@ test('--dry-run 印出將建立的 PR 與將停的錶,但不碰 Gitea', async
json.data.requests.map((r) => `${r.method} ${r.path}`), json.data.requests.map((r) => `${r.method} ${r.path}`),
[ [
`POST /repos/${REPO}/pulls`, `POST /repos/${REPO}/pulls`,
`POST /repos/${REPO}/issues/${INDEX}/stopwatch/stop`, `POST /repos/${ISSUE_REPO}/issues/${INDEX}/stopwatch/stop`,
], ],
); );
assert.equal(json.data.requests[0].body.title, HEAD, '試跑要看得到標題長什麼樣'); assert.equal(json.data.requests[0].body.title, HEAD, '試跑要看得到標題長什麼樣');
@@ -295,7 +419,7 @@ test('--dry-run 照樣驗描述:不合格的描述不該等到實跑才發現'
const stub = await withStub(t); const stub = await withStub(t);
const file = bodyFile('dry-bad', BODY.replace('## 測試結果', '## 測試結論')); const file = bodyFile('dry-bad', BODY.replace('## 測試結果', '## 測試結論'));
const { json } = await run(['--body-file', file, '--dry-run'], stub); const { json } = await runFull(file, stub, ['--dry-run']);
assert.equal(json.error.code, 'MISSING_SECTION'); assert.equal(json.error.code, 'MISSING_SECTION');
}); });