From de1fd08efa3c7a0dff9e410961f2f3f3d9adb4f4 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 08:46:33 +0000 Subject: [PATCH] =?UTF-8?q?test(pr-comments):=20=E8=AE=80=20PR=20=E4=B8=8A?= =?UTF-8?q?=E7=9A=84=E4=B8=89=E9=A1=9E=E7=95=99=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一般留言、review 總評、行內留言分散在三個端點,漏掉任何一類就會有意見沒被處理—— 而那正是 /sdlc-fix 存在的理由。只讀不寫,必改/建議的分類由讀到內容的人判斷。 三類的「已處理」機制不同:一般留言與總評看自己打的 +1,行內留言看有沒有被 resolve。 總評的 reaction 掛在它在 issue comment 表裡那一份的 id 上,由 timeline 對應得出來; 拿 review 自己的 id 去打會 404,兩個 id 不同命名空間。 reaction 要是自己打的才算已處理:reviewer 對留言按讚是「我同意」,不是「這則處理過了」, 當成已處理會讓那一則被靜靜跳過。 行內留言的位置分兩側:新檔那側在 position,被刪掉的那行在 original_position,Gitea 只填 其中一個。輸出把兩者收斂成「行」與「側」,回覆時才知道該送哪個欄位。也帶出 commit, 讓回覆落在原留言的那個 commit 上。 三種清單都逐頁讀完:這個站台的預設頁大小是 30,沒分頁的話第 31 個 review 之後整批消失。 --- test/pr-comments.test.js | 417 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 417 insertions(+) create mode 100644 test/pr-comments.test.js diff --git a/test/pr-comments.test.js b/test/pr-comments.test.js new file mode 100644 index 0000000..7456048 --- /dev/null +++ b/test/pr-comments.test.js @@ -0,0 +1,417 @@ +/** + * 讀 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' }, + } = options; + + const base = healthyRoutes(REPO, { + [`GET /api/v1/repos/${REPO}/pulls/${INDEX}`]: { status: 200, body: pull }, + [`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('PR 不存在時回可區分的錯誤碼', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/pulls/${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.match(json.error.message, new RegExp(String(INDEX))); +}); + +// ── --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 /user', + `GET /repos/${REPO}/pulls/${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/); + 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).內容, '最後一則'); +});