test(pr-comments): 讀 PR 上的三類留言

一般留言、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 之後整批消失。
This commit is contained in:
2026-09-17 08:46:33 +00:00
parent cc29b5bc21
commit de1fd08efa
+417
View File
@@ -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).內容, '最後一則');
});