/** * 讀 PR 上的三類留言。 * * 三類分散在三個端點,漏掉任何一類就會有 reviewer 的意見沒被處理——而那正是這一段 * 存在的理由。所以測試的重點是「三類都讀到」與「每一則都說得出它能不能被標記」。 * * 「已處理」在三類上的機制不同:一般留言與 review 總評看**自己打的** `+1` reaction, * 行內留言看它有沒有被 resolve。總評的 reaction 掛在它的 issue comment id 上, * 不是 review id——兩個 id 不同命名空間,弄錯會 404。 */ import test from 'node:test'; import assert from 'node:assert/strict'; import { runScript } from './helpers/run-script.js'; import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js'; const REPO = 'plugins/tea-sdlc'; const INDEX = 45; /** * 組出一份假的 PR 留言現場。 * @param {object} options general/reviews 各自的內容 */ function routes(overrides = {}, options = {}) { const { 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}/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) => ({ id: 100 + i, body: c.body, user: { login: c.user ?? 'reviewer' }, })), }, [`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: { status: 200, body: reviews.map((r, i) => ({ id: 200 + i, body: r.body ?? '', state: r.state ?? 'COMMENT', user: { login: r.user ?? 'reviewer' }, })), }, // 總評在 issue comment 表裡也有一份,timeline 給得出它的 id 與 review_id [`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: { status: 200, body: reviews.map((r, i) => ({ id: 400 + i, type: 'review', review_id: 200 + i, body: r.body ?? '', user: { login: r.user ?? 'reviewer' }, })), }, }); general.forEach((c, i) => { base[`GET /api/v1/repos/${REPO}/issues/comments/${100 + i}/reactions`] = { status: 200, body: (c.reactions ?? []).map((content) => ({ content, user: { login: c.reactedBy ?? 'tester' } })), }; }); reviews.forEach((r, i) => { // 總評那一則的 reaction 掛在它的 issue comment id 上 base[`GET /api/v1/repos/${REPO}/issues/comments/${400 + i}/reactions`] = { status: 200, body: (r.reactions ?? []).map((content) => ({ content, user: { login: 'tester' } })), }; base[`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews/${200 + i}/comments`] = { status: 200, body: (r.comments ?? []).map((c, j) => ({ id: 300 + i * 10 + j, body: c.body, path: c.path, position: c.position ?? 1, diff_hunk: c.diff ?? '@@ -1 +1 @@', user: { login: c.user ?? r.user ?? 'reviewer' }, resolver: c.resolved ? { login: 'someone' } : null, })), }; }); return { ...base, ...overrides }; } const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options)); const run = (args, stub) => runScript('pr-comments.js', ['--repo', REPO, '--index', String(INDEX), ...args], { env: envFor(stub), }); /** 一份三類俱全的現場 */ const FULL = { general: [ { body: '整體方向沒問題,但命名再想想。', reactions: [] }, { body: '這個我已經處理過了。', reactions: ['+1'] }, ], reviews: [ { body: '大致可以,兩個地方要改。', state: 'REQUEST_CHANGES', comments: [ { body: '這裡少了錯誤處理。', path: 'scripts/claim.js', position: 12 }, { body: '這行可以刪掉。', path: 'scripts/claim.js', position: 30, resolved: true }, ], }, { body: '', state: 'APPROVED', comments: [{ body: '順手提一下拼字。', path: 'README.md', position: 3 }] }, ], }; // ── 三類都要讀到 ─────────────────────────────────────────────────── test('一般留言、review 總評、行內留言三類都讀得到', async (t) => { const stub = await withStub(t, {}, FULL); const { code, json } = await run([], stub); assert.equal(code, 0, JSON.stringify(json)); const kinds = json.data.留言.map((c) => c.類型); assert.equal(kinds.filter((k) => k === '一般').length, 2); assert.equal(kinds.filter((k) => k === '總評').length, 1, '只有 body 非空的 review 算總評'); assert.equal(kinds.filter((k) => k === '行內').length, 3); }); test('body 是空的 review 不算總評:那是純粹的行內留言容器', async (t) => { const stub = await withStub(t, {}, FULL); const { json } = await run([], stub); const 總評 = json.data.留言.filter((c) => c.類型 === '總評'); assert.deepEqual(總評.map((c) => c.內容), ['大致可以,兩個地方要改。']); }); test('行內留言帶著檔案、行號與 diff 片段,agent 才看得懂在說哪裡', async (t) => { const stub = await withStub(t, {}, FULL); const { json } = await run([], stub); const 行內 = json.data.留言.find((c) => c.類型 === '行內'); assert.equal(行內.檔案, 'scripts/claim.js'); assert.equal(行內.行, 12); assert.match(行內.diff, /@@/); }); test('每一則都帶 id 與作者', async (t) => { const stub = await withStub(t, {}, FULL); const { json } = await run([], stub); for (const comment of json.data.留言) { assert.equal(typeof comment.id, 'number', JSON.stringify(comment)); assert.equal(typeof comment.作者, 'string'); } }); // ── 已處理的判斷:三類各有各的機制 ───────────────────────────────── test('一般留言看 +1 reaction', async (t) => { const stub = await withStub(t, {}, FULL); const { json } = await run([], stub); const 一般 = json.data.留言.filter((c) => c.類型 === '一般'); assert.deepEqual(一般.map((c) => c.已處理), [false, true]); }); test('行內留言看它有沒有被 resolve', async (t) => { const stub = await withStub(t, {}, FULL); const { json } = await run([], stub); const 行內 = json.data.留言.filter((c) => c.類型 === '行內'); assert.deepEqual(行內.map((c) => c.已處理), [false, true, false]); }); test('三類都標記得了:總評用它在 issue comment 表裡的那一份', async (t) => { // Gitea 的 review 總評在 issue comment 表裡也有一份,reaction 掛在那個 id 上。 // 用 review 自己的 id 去打 reaction 會 404——兩個 id 不同命名空間。 const stub = await withStub(t, {}, FULL); const { json } = await run([], stub); for (const comment of json.data.留言) { assert.equal(comment.可標記, true, `${comment.類型}應該標記得了`); } }); test('總評的 id 是它在 issue comment 表裡的 id,不是 review id', async (t) => { const stub = await withStub(t, {}, FULL); const { json } = await run([], stub); const 總評 = json.data.留言.find((c) => c.類型 === '總評'); assert.equal(總評.id, 400, 'timeline 給的 comment id'); assert.equal(總評.review, 200, 'review id 另外帶著,行內留言要靠它查'); }); test('總評的已處理看它自己那則 comment 的 +1', async (t) => { const stub = await withStub(t, {}, { reviews: [ { body: '這則處理過了', reactions: ['+1'] }, { body: '這則還沒', reactions: [] }, ], }); const { json } = await run([], stub); assert.deepEqual(json.data.留言.map((c) => c.已處理), [true, false]); }); test('別人打的 +1 不算已處理:那是「我同意」,不是「我處理過了」', async (t) => { // reviewer 對自己的留言按讚很常見;當成已處理的話,那一則會被靜靜跳過 const stub = await withStub(t, {}, { general: [ { body: '自己打的', reactions: ['+1'], reactedBy: 'tester' }, { body: '別人打的', reactions: ['+1'], reactedBy: 'reviewer' }, ], }); const { json } = await run([], stub); assert.deepEqual(json.data.留言.map((c) => c.已處理), [true, false]); }); test('未處理數只算還沒處理的,且與明細對得上', async (t) => { const stub = await withStub(t, {}, FULL); const { json } = await run([], stub); assert.equal(json.data.未處理數, json.data.留言.filter((c) => !c.已處理).length); assert.equal(json.data.未處理數, 4); }); test('留在刪除行的行內留言,位置在 original_position 上', async (t) => { // Gitea 只填 position 與 original_position 其中一個:新檔那一側用 position, // 舊檔(被刪掉的行)那一側用 original_position。只讀 position 會得到 undefined。 const stub = await withStub(t, { [`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews/200/comments`]: { status: 200, body: [{ id: 350, body: '這一行為什麼刪掉?', path: 'scripts/old.js', position: 0, original_position: 7, diff_hunk: '@@ -7 +0 @@', user: { login: 'r' }, resolver: null, }], }, }, { reviews: [{ body: '', comments: [] }] }); const { json } = await run([], stub); const 行內 = json.data.留言.find((c) => c.類型 === '行內'); assert.equal(行內.行, 7); assert.equal(行內.側, '舊', '回覆時要知道它在哪一側,否則位置會送錯欄位'); }); test('一般的行內留言在新檔那一側', async (t) => { const stub = await withStub(t, {}, FULL); const 行內 = (await run([], stub)).json.data.留言.find((c) => c.類型 === '行內'); assert.equal(行內.側, '新'); }); // ── 分頁:三類都要讀完 ───────────────────────────────────────────── test('review 逐頁讀完:超過一頁就漏掉總評與行內留言', async (t) => { // 這個站台的預設頁大小是 30;沒分頁的話,第 31 個 review 之後整批消失, // 而「不漏掉任何一則」正是這個指令存在的理由 const page1 = Array.from({ length: 50 }, (_, i) => ({ id: 600 + i, body: `第 ${i} 則總評`, state: 'COMMENT', user: { login: 'r' }, })); const page2 = [{ id: 700, body: '最後一則總評', state: 'COMMENT', user: { login: 'r' } }]; const extra = {}; for (const r of [...page1, ...page2]) { extra[`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews/${r.id}/comments`] = { status: 200, body: [] }; } const stub = await withStub(t, { [`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: (req) => ({ status: 200, body: req.query.page === '1' ? page1 : page2, }), [`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: (req) => ({ status: 200, body: (req.query.page === '1' ? page1 : page2).map((r) => ({ id: r.id + 5000, type: 'review', review_id: r.id, body: r.body, user: r.user, })), }), ...extra, ...Object.fromEntries([...page1, ...page2].map((r) => [ `GET /api/v1/repos/${REPO}/issues/comments/${r.id + 5000}/reactions`, { status: 200, body: [] }, ])), }, {}); const { json } = await run([], stub); assert.equal(json.data.留言.length, 51); assert.equal(json.data.留言.at(-1).內容, '最後一則總評'); }); // ── PENDING 的 review 還沒送出 ───────────────────────────────────── test('PENDING 的 review 不算數:它還沒送出,reviewer 自己也看不到', async (t) => { const stub = await withStub(t, {}, { reviews: [ { body: '寫到一半的草稿', state: 'PENDING', comments: [{ body: '草稿裡的行內', path: 'a.js' }] }, { body: '送出來的', state: 'COMMENT' }, ], }); const { json } = await run([], stub); assert.deepEqual(json.data.留言.map((c) => c.內容), ['送出來的']); }); // ── 空留言與系統事件 ─────────────────────────────────────────────── test('內容是空的一般留言不算:那多半是狀態變更的系統紀錄', async (t) => { const stub = await withStub(t, {}, { general: [{ body: '' }, { body: ' ' }, { body: '真的留言' }], }); const { json } = await run([], stub); assert.deepEqual(json.data.留言.map((c) => c.內容), ['真的留言']); }); test('完全沒有留言時回空陣列,不報錯', async (t) => { const stub = await withStub(t, {}, {}); const { code, json } = await run([], stub); assert.equal(code, 0); assert.deepEqual(json.data.留言, []); assert.equal(json.data.未處理數, 0); }); // ── PR 本身 ─────────────────────────────────────────────────────── test('帶出 PR 的識別資訊,讓回報不必再查一次', async (t) => { const stub = await withStub(t, {}, FULL); const { json } = await run([], stub); assert.equal(json.data.index, INDEX); assert.equal(json.data.title, 'feat/pr-comments/main'); assert.equal(json.data.url, `https://x/${INDEX}`); assert.equal(json.data.state, 'open'); }); test('議題不存在時回可區分的錯誤碼', async (t) => { const stub = await withStub(t, { [`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, '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) => { const stub = await withStub(t, {}, FULL); const { code, json } = await run(['--dry-run'], stub); assert.equal(code, 0); assert.equal(json.data.dryRun, true); assert.deepEqual( json.data.requests.map((r) => `${r.method} ${r.path}`), [ `GET /repos/${REPO}/issues/${INDEX}`, `GET /repos/${REPO}/issues/${INDEX}/comments`, ], ); assert.match(json.data.note, /reaction|review/); assert.equal(stub.requests.length, 0); }); // ── 分頁 ─────────────────────────────────────────────────────────── test('留言逐頁讀完,不是只讀第一頁', async (t) => { const page1 = Array.from({ length: 50 }, (_, i) => ({ id: 500 + i, body: `第 ${i} 則`, user: { login: 'r' } })); const page2 = [{ id: 999, body: '最後一則', user: { login: 'r' } }]; const reactions = {}; for (const c of [...page1, ...page2]) { reactions[`GET /api/v1/repos/${REPO}/issues/comments/${c.id}/reactions`] = { status: 200, body: [] }; } const stub = await withStub(t, { [`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: (req) => ({ status: 200, body: req.query.page === '1' ? page1 : page2, }), ...reactions, }, {}); const { json } = await run([], stub); assert.equal(json.data.留言.length, 51); assert.equal(json.data.留言.at(-1).內容, '最後一則'); });