Files
tea-sdlc/test/pr-comments.test.js
T
jiantw83 de1fd08efa 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 之後整批消失。
2026-09-17 08:46:33 +00:00

418 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 讀 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).內容, '最後一則');
});