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:
@@ -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).內容, '最後一則');
|
||||
});
|
||||
Reference in New Issue
Block a user