Merge pull request 'feat/comments-merge/main' (#52) from feat/comments-merge/main into master

Reviewed-on: #52
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
This commit was merged in pull request #52.
This commit is contained in:
2026-09-17 09:22:58 +00:00
17 changed files with 976 additions and 95 deletions
+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`],
);
});
+1 -1
View File
@@ -85,7 +85,7 @@ function routes(overrides = {}, { body = FULL_BODY, comments = [] } = {}) {
comments.forEach((c, i) => {
base[`GET /api/v1/repos/${REPO}/issues/comments/${100 + i}/reactions`] = {
status: 200,
body: (c.reactions ?? []).map((content) => ({ content })),
body: (c.reactions ?? []).map((content) => ({ content, user: { login: c.reactedBy ?? 'tester' } })),
};
});
return { ...base, ...overrides };
+39 -8
View File
@@ -25,10 +25,14 @@ function routes(overrides = {}, options = {}) {
general = [],
reviews = [],
pull = { number: INDEX, title: 'feat/pr-comments/main', html_url: `https://x/${INDEX}`, state: 'open' },
isPull = true,
} = options;
const base = healthyRoutes(REPO, {
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}`]: { status: 200, body: pull },
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: {
status: 200,
body: { ...pull, ...(isPull ? { pull_request: { merged: false } } : {}) },
},
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: {
status: 200,
body: general.map((c, i) => ({
@@ -357,18 +361,48 @@ test('帶出 PR 的識別資訊,讓回報不必再查一次', async (t) => {
assert.equal(json.data.state, 'open');
});
test('PR 不存在時回可區分的錯誤碼', async (t) => {
test('議題不存在時回可區分的錯誤碼', async (t) => {
const stub = await withStub(t, {
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}`]: { status: 404, body: { message: 'not found' } },
[`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 404, body: { message: 'not found' } },
}, FULL);
const { code, json } = await run([], stub);
assert.equal(code, 1);
assert.equal(json.error.code, 'PULL_NOT_FOUND');
assert.equal(json.error.code, 'ISSUE_NOT_FOUND');
assert.match(json.error.message, new RegExp(String(INDEX)));
});
test('純議題也讀得到:先讀 issue 再決定要不要翻 review', async (t) => {
// 每個 PR 都是議題,議題不一定是 PR。先打 /pulls 的話,純議題會 404,
// 而 /sdlc-sync 的輸入正是純議題——整個流程在讀到第一則留言之前就斷了
const stub = await withStub(t, {}, {
isPull: false,
general: [{ body: '這顆議題上的決策', reactions: [] }],
reviews: [],
});
const { code, json } = await run([], stub);
assert.equal(code, 0, JSON.stringify(json));
assert.equal(json.data.類型, '議題');
assert.deepEqual(json.data.留言.map((c) => c.內容), ['這顆議題上的決策']);
assert.equal(
stub.requests.some((r) => r.path.includes('/pulls/')),
false,
'純議題不該去打 PR 的端點',
);
});
test('是 PR 時類型標成 PR,並照樣讀 review', async (t) => {
const stub = await withStub(t, {}, FULL);
const { json } = await run([], stub);
assert.equal(json.data.類型, 'PR');
assert.ok(json.data.留言.some((c) => c.類型 === '總評'));
});
// ── --dry-run ─────────────────────────────────────────────────────
test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => {
@@ -381,11 +415,8 @@ test('--dry-run 印出將發出的請求,且不碰 Gitea', async (t) => {
assert.deepEqual(
json.data.requests.map((r) => `${r.method} ${r.path}`),
[
'GET /user',
`GET /repos/${REPO}/pulls/${INDEX}`,
`GET /repos/${REPO}/issues/${INDEX}`,
`GET /repos/${REPO}/issues/${INDEX}/comments`,
`GET /repos/${REPO}/pulls/${INDEX}/reviews`,
`GET /repos/${REPO}/issues/${INDEX}/timeline`,
],
);
assert.match(json.data.note, /reaction|review/);
+1 -1
View File
@@ -391,7 +391,7 @@ test('描述檔不存在時回可區分的錯誤碼', async (t) => {
stub,
);
assert.equal(json.error.code, 'BODY_FILE_NOT_FOUND');
assert.equal(json.error.code, 'FILE_NOT_FOUND');
});
// ── --dry-run ─────────────────────────────────────────────────────
+151
View File
@@ -0,0 +1,151 @@
/**
* /sdlc-sync 的流程正本,以及另外兩份正本的「接回」那一段。
*
* 這個指令的價值在於「描述不再騙人」,而三件關鍵事只有正本做得到:挑出哪些留言真的是
* 決策、寫回去之前讓使用者點頭、以及略過的那幾則要保持未標記。寫漏任何一件,
* 描述就會繼續過期,或者有決策被靜靜吞掉。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { assertNeutralPrompt, readPrompt } from './helpers/prompt-doc.js';
const prompt = readPrompt('sdlc-sync');
const steps = prompt.slice(prompt.indexOf('## 1.'), prompt.indexOf('## 邊界'));
test('正本平台中立,description 前綴正確', () => {
assertNeutralPrompt(prompt, 'sdlc-sync');
});
test('兩種議題各自指名對應的抽取腳本', () => {
assert.match(steps, /issue-extract/);
assert.match(steps, /wp-extract/);
assert.match(steps, /需求議題.*工作包議題|工作包議題.*需求議題/s);
});
test('留言內容另外拿,並說明為什麼抽取契約不夠', () => {
assert.match(steps, /pr-comments/);
assert.match(steps, /只給數字不給內容/);
});
test('說明那支腳本議題與 PR 都收得下,不再宣稱「PR 也是 issue」', () => {
assert.match(steps, /議題與 PR 都收得下/);
assert.equal(steps.includes('Gitea 的 PR 也是 issue'), false, '反過來說才對:每個 PR 都是議題');
});
test('已處理認的是自己打的 +1', () => {
assert.match(steps, /自己打的/);
assert.match(steps, /我同意/);
});
test('略過會讓計數停在大於 0,這件事要講明白', () => {
const section = steps.slice(steps.indexOf('## 5.'));
assert.match(section, /停在大於 0/);
assert.match(section, /每次開始時都會再停一次/);
assert.match(section, /他選了略過/, '要讓使用者分得出「沒整併乾淨」與「選了略過」');
});
test('已標記過的留言跳過', () => {
assert.match(steps, /已經標記過的留言不再處理|已處理.*跳過/s);
});
// ── 挑決策 ─────────────────────────────────────────────────────────
test('要整併與不整併各有判斷依據,不是只給兩個詞', () => {
assert.match(steps, /\*\*要整併\*\* — .{10,}/);
assert.match(steps, /\*\*不整併\*\* — .{10,}/);
});
test('判斷不了時當成要整併,並說出漏掉的代價', () => {
assert.match(steps, /判斷不了的\*\*當成要整併\*\*/);
assert.match(steps, /描述就會繼續騙後面的人/);
});
// ── 先點頭再寫 ─────────────────────────────────────────────────────
test('寫回去之前要列出方案給使用者看', () => {
assert.match(steps, /先列出來再動手/);
assert.match(steps, /哪一段/);
assert.match(steps, /改完長什麼樣/);
});
test('一次問一題,且略過的會保持未標記', () => {
assert.match(steps, /一次問一題/);
assert.match(steps, /略過的留言保持未標記/);
assert.match(steps, /下次執行還會被提出來/);
});
test('說明了這一步是描述的最後一道關卡', () => {
assert.match(steps, /最後一道關卡/);
assert.match(steps, /拿它當事實/);
});
// ── 寫回 ───────────────────────────────────────────────────────────
test('指名 comments-merge,並要求先試跑', () => {
assert.match(steps, /comments-merge\.js/);
assert.match(steps, /--dry-run/);
});
test('--content-file 的內容是整段,且說明標題那一行不含在內', () => {
assert.match(steps, /那一段改完的完整內容/);
assert.match(steps, /不含 `## 標題` 那一行/);
});
test('--merged 只放真的併進去的,並說出放錯的後果', () => {
assert.match(steps, /只放\*\*真的被併進這一段\*\*/);
assert.match(steps, /再也\n?不會被提出來|再也不會被提出來/);
});
test('SECTION_NOT_FOUND 的處置是回頭確認,不是硬塞別的段落', () => {
assert.match(steps, /SECTION_NOT_FOUND/);
assert.match(steps, /不要改用別的段落硬塞/);
});
// ── 回報 ───────────────────────────────────────────────────────────
test('回報要列出略過的那幾則', () => {
const section = steps.slice(steps.indexOf('## 5.'));
assert.match(section, /略過的那幾則是哪些/);
assert.match(section, /下次還會出現/);
});
// ── 接回原本的指令 ─────────────────────────────────────────────────
test('整併完自動接回,不要求使用者重打指令', () => {
const section = prompt.slice(prompt.indexOf('## 接回原本的指令'));
assert.match(section, /直接接回去/);
assert.match(section, /不要要求使用者重打一次/);
});
test('接回之前要重新抽取,並說明為什麼', () => {
const section = prompt.slice(prompt.indexOf('## 接回原本的指令'));
assert.match(section, /先重跑一次抽取/);
assert.match(section, /這一整段就白做了/);
});
// ── 邊界 ───────────────────────────────────────────────────────────
test('邊界列出不做的事,含不刪改留言', () => {
const boundary = prompt.slice(prompt.indexOf('## 邊界'));
assert.match(boundary, /不自行決定要不要整併/);
assert.match(boundary, /不整份重寫描述/);
assert.match(boundary, /不標記略過的留言/);
assert.match(boundary, /不刪除、不編輯任何留言/);
assert.match(boundary, /誰說過什麼的紀錄/, '要說出為什麼留言不能動');
});
// ── 另外兩份正本的接回 ─────────────────────────────────────────────
for (const name of ['sdlc-analyze', 'sdlc-feat']) {
test(`${name} 偵測到未整併留言時會轉去整併,並自動接回`, () => {
const other = readPrompt(name);
assert.match(other, /未處理留言數/);
assert.match(other, /直接走 `\/sdlc-sync` 的流程/, `${name} 要真的轉過去,不是叫人自己跑`);
assert.match(other, /自動接回這裡/);
assert.match(other, /不要要求使用者重打指令/);
});
test(`${name} 接回前要重新抽取一次`, () => {
assert.match(readPrompt(name), /重新抽取一次拿到更新後的描述/);
});
}
+1 -1
View File
@@ -106,7 +106,7 @@ function routes(overrides = {}, options = {}) {
comments.forEach((c, i) => {
base[`GET /api/v1/repos/${REPO}/issues/comments/${100 + i}/reactions`] = {
status: 200,
body: (c.reactions ?? []).map((content) => ({ content })),
body: (c.reactions ?? []).map((content) => ({ content, user: { login: c.reactedBy ?? 'tester' } })),
};
});
return { ...base, ...overrides };