test(comments-merge): 把留言裡的決策整併回議題描述並標記
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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`],
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user