Files
tea-sdlc/test/pr-comments.test.js
T
jiantw83 437896348a test(留言分頁): 改用 timeline 分頁讀取留言
Gitea 議題留言端點無法可靠處理分頁;改由 timeline 逐頁篩選 comment 事件並去重,讓抽取、整併與 PR 留言流程取得完整集合。
2026-09-22 14:37:38 +08:00

453 lines
16 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' },
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}/timeline`]: {
status: 200,
body: [
...general.map((c, i) => ({
id: 100 + i,
type: 'comment',
body: c.body,
user: { login: c.user ?? 'reviewer' },
})),
...reviews.map((r, i) => ({
id: 400 + i,
type: 'review',
review_id: 200 + i,
body: r.body ?? '',
user: { login: r.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' },
})),
},
});
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}/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,
type: 'comment',
body: `第 ${i} 則`,
user: { login: 'r' },
}));
const page2 = [{ id: 999, type: 'comment', 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}/timeline`]: (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).內容, '最後一則');
});