test(comments-merge): 把留言裡的決策整併回議題描述並標記

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 09:19:10 +00:00
co-authored by Claude Opus 5
parent 16f93f6e8b
commit 3ad08be14f
+335
View File
@@ -0,0 +1,335 @@
/**
* 把留言裡的決策整併回議題描述。
*
* 兩件事錯了都很安靜,所以測試集中在這裡:
*
* 1. **局部更新。** 只換指定那一段,其餘一字不動。整份重寫會把別人在其他段落的
* 編輯一起蓋掉,而議題的編輯紀錄沒有人會去比對。
* 2. **標記只給真的整併進去的那幾則。** 略過的要保持未標記,下次才會再被提出來;
* 而描述沒寫成功就不該標記——標了就等於這則再也不會被看到。
*/
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, patchOf } from './helpers/stub-gitea.js';
const REPO = 'plugins/tea-sdlc';
const INDEX = 7;
/** 一份有多個段落的需求議題 */
const BODY = `## 總覽
把一段口語需求變成結構化議題。
## 背景
需求目前寫成散文。
## 目標
- 需求議題可被下游腳本機讀
- 建立議題的時間從 30 分鐘降到 5 分鐘
## 非目標
- 不處理工作包的拆解
`;
/** 把段落內容寫成檔案,回傳路徑 */
function contentFile(name, content) {
mkdirSync(tmpRoot, { recursive: true });
const path = join(tmpRoot, `merge-${name}-${process.hrtime.bigint()}.md`);
writeFileSync(path, content);
return path;
}
function routes(overrides = {}, { body = BODY, comments = [101, 102] } = {}) {
const base = healthyRoutes(REPO, {
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
status: 200,
body: { number: INDEX, title: '以 sdlc-plan 轉成結構化需求議題', body, html_url: `https://x/${INDEX}` },
},
[`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`]: (req) => ({
status: 200,
body: { number: INDEX, ...req.body, html_url: `https://x/${INDEX}` },
}),
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: {
status: 200,
body: comments.map((id) => ({ id, body: `留言 ${id}`, user: { login: 'someone' } })),
},
});
for (const id of comments) {
base[`POST /api/v1/repos/${REPO}/issues/comments/${id}/reactions`] = { status: 201, body: {} };
}
return { ...base, ...overrides };
}
const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options));
const run = (args, stub) =>
runScript('comments-merge.js', ['--repo', REPO, '--index', String(INDEX), ...args], {
env: envFor(stub),
});
const reactions = (stub) =>
stub.requests.filter((r) => r.method === 'POST' && r.path.includes('/reactions'));
// ── 局部更新 ───────────────────────────────────────────────────────
test('只換指定那一段,其餘一字不動', async (t) => {
const stub = await withStub(t);
const file = contentFile('goals', '- 需求議題可被下游腳本機讀\n- 建立議題的時間降到 5 分鐘\n- 名詞表由需求提出者維護\n');
const { code, json } = await run(
['--section', '目標', '--content-file', file, '--merged', '101'],
stub,
);
assert.equal(code, 0, JSON.stringify(json));
const written = patchOf(stub).body.body;
assert.match(written, /- 名詞表由需求提出者維護/, '新內容要寫進去');
assert.match(written, /## 總覽\n\n把一段口語需求變成結構化議題。/, '總覽原封不動');
assert.match(written, /## 背景\n\n需求目前寫成散文。/, '背景原封不動');
assert.match(written, /## 非目標\n\n- 不處理工作包的拆解/, '非目標原封不動');
});
test('段落標題本身不動,只換它底下的內容', async (t) => {
const stub = await withStub(t);
const file = contentFile('keep-heading', '- 換掉的內容\n');
await run(['--section', '目標', '--content-file', file, '--merged', '101'], stub);
const written = patchOf(stub).body.body;
assert.equal((written.match(/^## 目標$/gm) ?? []).length, 1, '標題只有一個,沒有被複製或刪掉');
assert.match(written, /## 目標\n\n- 換掉的內容\n\n## 非目標/);
});
test('段落之間的空行維持原本的樣子', async (t) => {
const stub = await withStub(t);
const file = contentFile('spacing', '- 甲\n');
await run(['--section', '背景', '--content-file', file, '--merged', '101'], stub);
assert.match(patchOf(stub).body.body, /## 背景\n\n- 甲\n\n## 目標/);
});
test('最後一段也換得掉', async (t) => {
const stub = await withStub(t);
const file = contentFile('last', '- 也不處理權限\n');
await run(['--section', '非目標', '--content-file', file, '--merged', '101'], stub);
const written = patchOf(stub).body.body;
assert.match(written, /## 非目標\n\n- 也不處理權限/);
assert.equal(written.includes('不處理工作包的拆解'), false, '舊內容要被換掉');
});
test('段落不存在時擋下,不把內容補到別的地方去', async (t) => {
const stub = await withStub(t);
const file = contentFile('missing', '- 內容\n');
const { code, json } = await run(
['--section', '沒有這一段', '--content-file', file, '--merged', '101'],
stub,
);
assert.equal(code, 1);
assert.equal(json.error.code, 'SECTION_NOT_FOUND');
assert.equal(patchOf(stub), undefined);
assert.deepEqual(reactions(stub), [], '沒寫進去就不該標記');
});
test('圍欄裡的假標題不算段落', async (t) => {
const body = '## 流程圖\n\n```\n## 目標\n這不是段落\n```\n\n## 目標\n\n- 真的目標\n';
const stub = await withStub(t, {}, { body });
const file = contentFile('fenced', '- 換掉的目標\n');
await run(['--section', '目標', '--content-file', file, '--merged', '101'], stub);
const written = patchOf(stub).body.body;
assert.match(written, /```\n## 目標\n這不是段落\n```/, '圍欄裡的內容原封不動');
assert.match(written, /## 目標\n\n- 換掉的目標/);
});
// ── 標記:只給真的整併進去的 ───────────────────────────────────────
test('只標記 --merged 列出的那幾則', async (t) => {
const stub = await withStub(t, {}, { comments: [101, 102, 103] });
const file = contentFile('partial', '- 內容\n');
const { json } = await run(
['--section', '目標', '--content-file', file, '--merged', '101,103'],
stub,
);
assert.deepEqual(json.data.已標記, [101, 103]);
assert.deepEqual(
reactions(stub).map((r) => r.path),
[
`/api/v1/repos/${REPO}/issues/comments/101/reactions`,
`/api/v1/repos/${REPO}/issues/comments/103/reactions`,
],
'沒被整併的 102 要保持未標記,下次才會再被提出來',
);
});
test('標記送的是 +1', async (t) => {
const stub = await withStub(t);
const file = contentFile('thumb', '- 內容\n');
await run(['--section', '目標', '--content-file', file, '--merged', '101'], stub);
assert.deepEqual(reactions(stub)[0].body, { content: '+1' });
});
test('先寫描述再標記:標記是「這則已經收進去了」的結論', async (t) => {
const stub = await withStub(t);
const file = contentFile('order', '- 內容\n');
await run(['--section', '目標', '--content-file', file, '--merged', '101'], stub);
const writes = stub.requests
.filter((r) => r.method !== 'GET' && !r.path.endsWith('/issues/0'))
.map((r) => r.path);
assert.ok(
writes.indexOf(`/api/v1/repos/${REPO}/issues/${INDEX}`)
< writes.indexOf(`/api/v1/repos/${REPO}/issues/comments/101/reactions`),
);
});
test('描述寫入失敗時不標記:標了就等於這則再也不會被看到', async (t) => {
const stub = await withStub(t, {
[`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 403, body: { message: 'forbidden' } },
});
const file = contentFile('fail', '- 內容\n');
const { code } = await run(['--section', '目標', '--content-file', file, '--merged', '101'], stub);
assert.equal(code, 1);
assert.deepEqual(reactions(stub), []);
});
test('--merged 指到議題上沒有的留言時擋下', async (t) => {
const stub = await withStub(t, {}, { comments: [101] });
const file = contentFile('badid', '- 內容\n');
const { json } = await run(
['--section', '目標', '--content-file', file, '--merged', '101,999'],
stub,
);
assert.equal(json.error.code, 'COMMENT_NOT_FOUND');
assert.match(json.error.message, /999/);
assert.equal(patchOf(stub), undefined);
});
// ── 冪等 ───────────────────────────────────────────────────────────
test('內容與現況相同時不重寫描述,但該標記的還是要標', async (t) => {
// 重跑常常是因為上一輪標記那一步斷掉了
const stub = await withStub(t);
const file = contentFile('same', '- 需求議題可被下游腳本機讀\n- 建立議題的時間從 30 分鐘降到 5 分鐘\n');
const { code, json } = await run(
['--section', '目標', '--content-file', file, '--merged', '101'],
stub,
);
assert.equal(code, 0, JSON.stringify(json));
assert.equal(json.data.描述已更新, false);
assert.equal(patchOf(stub), undefined, '沒變就不要在議題上留一筆空的編輯');
assert.equal(reactions(stub).length, 1, '標記照舊');
});
// ── 輸入 ───────────────────────────────────────────────────────────
test('內容是空的時候擋下:整併不該把一段清空', async (t) => {
const stub = await withStub(t);
const file = contentFile('empty', ' \n');
const { json } = await run(
['--section', '目標', '--content-file', file, '--merged', '101'],
stub,
);
assert.equal(json.error.code, 'FILE_EMPTY');
assert.equal(patchOf(stub), undefined);
});
test('內容檔不存在時回可區分的錯誤碼', async (t) => {
const stub = await withStub(t);
const { json } = await run(
['--section', '目標', '--content-file', join(tmpRoot, '不存在.md'), '--merged', '101'],
stub,
);
assert.equal(json.error.code, 'FILE_NOT_FOUND');
});
test('沒有 --merged 時擋下:整併卻不標記,下次會重複處理同一則', async (t) => {
const stub = await withStub(t);
const file = contentFile('nomerged', '- 內容\n');
const { json } = await runScript('comments-merge.js', [
'--repo', REPO, '--index', String(INDEX), '--section', '目標', '--content-file', file,
], { env: envFor(stub) });
assert.equal(json.error.code, 'MISSING_FLAG');
assert.match(json.error.message, /--merged/);
});
// ── --dry-run ─────────────────────────────────────────────────────
test('--dry-run 印出改完的描述與將標記的留言,但不寫入', async (t) => {
const stub = await withStub(t);
const file = contentFile('dry', '- 換掉的目標\n');
const { code, json } = await run(
['--section', '目標', '--content-file', file, '--merged', '101,102', '--dry-run'],
stub,
);
assert.equal(code, 0);
assert.equal(json.data.dryRun, true);
assert.match(json.data.requests[0].body.body, /- 換掉的目標/);
assert.deepEqual(
json.data.requests.map((r) => `${r.method} ${r.path}`),
[
`PATCH /repos/${REPO}/issues/${INDEX}`,
`POST /repos/${REPO}/issues/comments/101/reactions`,
`POST /repos/${REPO}/issues/comments/102/reactions`,
],
);
assert.equal(patchOf(stub), undefined);
assert.deepEqual(reactions(stub), []);
});
test('--dry-run 遇到段落不存在一樣報錯,不會等到實跑才發現', async (t) => {
const stub = await withStub(t);
const file = contentFile('dry-bad', '- 內容\n');
const { json } = await run(
['--section', '沒有這一段', '--content-file', file, '--merged', '101', '--dry-run'],
stub,
);
assert.equal(json.error.code, 'SECTION_NOT_FOUND');
});
test('--dry-run 在內容沒變時不預告 PATCH', async (t) => {
const stub = await withStub(t);
const file = contentFile('dry-same', '- 需求議題可被下游腳本機讀\n- 建立議題的時間從 30 分鐘降到 5 分鐘\n');
const { json } = await run(
['--section', '目標', '--content-file', file, '--merged', '101', '--dry-run'],
stub,
);
assert.deepEqual(
json.data.requests.map((r) => `${r.method} ${r.path}`),
[`POST /repos/${REPO}/issues/comments/101/reactions`],
);
});