453 lines
16 KiB
JavaScript
453 lines
16 KiB
JavaScript
/**
|
||
* 讀 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).內容, '最後一則');
|
||
});
|