350 lines
13 KiB
JavaScript
350 lines
13 KiB
JavaScript
/**
|
|
* 回覆一則 PR 留言並標記已處理。
|
|
*
|
|
* 「回在 reviewer 原本那一串底下」對三類留言是三件不同的事:
|
|
* - 行內留言 → 新 review 帶一則指向**同一個檔案與同一行**的留言,Gitea 才會把它
|
|
* 排在原留言底下。位置抓錯就變成另開一串,reviewer 得自己找對應。
|
|
* - 一般留言 → PR 的一般留言是平的,沒有串;回覆就是新增一則,並引用原文開頭
|
|
* 讓人看得出在回誰。
|
|
* - review 總評 → 沒有 reaction 也沒有 resolve,回覆是唯一能留下的痕跡。
|
|
*
|
|
* 所以這一支的測試重點是「回對地方」與「標記用對機制」,而不是回覆的文字內容。
|
|
*/
|
|
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;
|
|
/** 行內留言的 id 與它所在的位置 */
|
|
const INLINE = { id: 301, path: 'scripts/claim.js', position: 12, commit: 'abc123' };
|
|
const GENERAL = { id: 101, body: '命名再想想。' };
|
|
/** 總評:review 表的 id 與它在 issue comment 表裡那一份的 id */
|
|
const REVIEW = { id: 201, commentId: 401, body: '大致可以,兩個地方要改。' };
|
|
|
|
function routes(overrides = {}) {
|
|
return healthyRoutes(REPO, {
|
|
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: {
|
|
status: 200,
|
|
body: [{ id: REVIEW.id, body: REVIEW.body, state: 'REQUEST_CHANGES', user: { login: 'r' } }],
|
|
},
|
|
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews/${REVIEW.id}/comments`]: {
|
|
status: 200,
|
|
body: [{
|
|
id: INLINE.id,
|
|
body: '這裡少了錯誤處理。',
|
|
path: INLINE.path,
|
|
position: INLINE.position,
|
|
commit_id: INLINE.commit,
|
|
user: { login: 'r' },
|
|
resolver: null,
|
|
}],
|
|
},
|
|
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: {
|
|
status: 200,
|
|
body: [
|
|
{ id: REVIEW.commentId, type: 'review', review_id: REVIEW.id, body: REVIEW.body },
|
|
{ id: GENERAL.id, type: 'comment', body: GENERAL.body, user: { login: 'r' } },
|
|
],
|
|
},
|
|
[`POST /api/v1/repos/${REPO}/issues/comments/${REVIEW.commentId}/reactions`]: { status: 201, body: {} },
|
|
[`POST /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: { status: 201, body: { id: 900 } },
|
|
[`POST /api/v1/repos/${REPO}/issues/comments/${GENERAL.id}/reactions`]: { status: 201, body: {} },
|
|
[`POST /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: { status: 200, body: { id: 901 } },
|
|
[`POST /api/v1/repos/${REPO}/pulls/comments/${INLINE.id}/resolve`]: { status: 200, body: {} },
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
const withStub = (t, overrides = {}) => withStubGitea(t, routes(overrides));
|
|
|
|
const run = (args, stub) =>
|
|
runScript('pr-reply.js', ['--repo', REPO, '--index', String(INDEX), ...args], {
|
|
env: envFor(stub),
|
|
});
|
|
|
|
const posted = (stub) =>
|
|
stub.requests.filter((r) => r.method === 'POST' && !r.path.endsWith('/issues/0'));
|
|
|
|
// ── 行內留言:回在同一個位置 ───────────────────────────────────────
|
|
|
|
test('回覆行內留言時,新留言指向同一個檔案與同一行', async (t) => {
|
|
const stub = await withStub(t);
|
|
|
|
const { code, json } = await run(
|
|
['--comment', String(INLINE.id), '--kind', 'inline', '--body', '已補上錯誤處理。'],
|
|
stub,
|
|
);
|
|
|
|
assert.equal(code, 0, JSON.stringify(json));
|
|
const review = posted(stub).find((r) => r.path.endsWith('/reviews'));
|
|
assert.deepEqual(review.body.comments, [
|
|
{ path: INLINE.path, new_position: INLINE.position, body: '已補上錯誤處理。' },
|
|
]);
|
|
assert.equal(review.body.event, 'COMMENT', '回覆不該順手把 PR 標成通過或要求變更');
|
|
assert.equal(
|
|
review.body.commit_id,
|
|
INLINE.commit,
|
|
'PR 之後又推了新 commit 時,行號在新 commit 上指的是別的程式碼',
|
|
);
|
|
});
|
|
|
|
test('留在刪除行的留言要送 old_position,不是 new_position', async (t) => {
|
|
const stub = await withStub(t, {
|
|
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews/${REVIEW.id}/comments`]: {
|
|
status: 200,
|
|
body: [{
|
|
id: INLINE.id,
|
|
body: '這一行為什麼刪掉?',
|
|
path: 'scripts/old.js',
|
|
position: 0,
|
|
original_position: 7,
|
|
commit_id: INLINE.commit,
|
|
user: { login: 'r' },
|
|
resolver: null,
|
|
}],
|
|
},
|
|
});
|
|
|
|
const { code, json } = await run(
|
|
['--comment', String(INLINE.id), '--kind', 'inline', '--body', '因為它已經沒有呼叫端了。'],
|
|
stub,
|
|
);
|
|
|
|
assert.equal(code, 0, JSON.stringify(json));
|
|
assert.deepEqual(posted(stub).find((r) => r.path.endsWith('/reviews')).body.comments, [
|
|
{ path: 'scripts/old.js', old_position: 7, body: '因為它已經沒有呼叫端了。' },
|
|
]);
|
|
});
|
|
|
|
test('行內留言回覆完會被 resolve', async (t) => {
|
|
const stub = await withStub(t);
|
|
|
|
const { json } = await run(
|
|
['--comment', String(INLINE.id), '--kind', 'inline', '--body', '已修正。'],
|
|
stub,
|
|
);
|
|
|
|
assert.ok(posted(stub).some((r) => r.path.endsWith(`/pulls/comments/${INLINE.id}/resolve`)));
|
|
assert.equal(json.data.已標記, true);
|
|
assert.equal(json.data.標記方式, 'resolve');
|
|
});
|
|
|
|
test('先回覆再標記:標記是「這一則處理完了」的結論', async (t) => {
|
|
const stub = await withStub(t);
|
|
|
|
await run(['--comment', String(INLINE.id), '--kind', 'inline', '--body', '已修正。'], stub);
|
|
|
|
const paths = posted(stub).map((r) => r.path);
|
|
assert.ok(
|
|
paths.indexOf(`/api/v1/repos/${REPO}/pulls/${INDEX}/reviews`)
|
|
< paths.indexOf(`/api/v1/repos/${REPO}/pulls/comments/${INLINE.id}/resolve`),
|
|
);
|
|
});
|
|
|
|
test('回覆失敗時不標記:沒回就標記等於謊稱處理過', async (t) => {
|
|
const stub = await withStub(t, {
|
|
[`POST /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: { status: 422, body: { message: 'bad position' } },
|
|
});
|
|
|
|
const { code } = await run(
|
|
['--comment', String(INLINE.id), '--kind', 'inline', '--body', '已修正。'],
|
|
stub,
|
|
);
|
|
|
|
assert.equal(code, 1);
|
|
assert.equal(posted(stub).some((r) => r.path.endsWith('/resolve')), false);
|
|
});
|
|
|
|
test('找不到那則行內留言時回可區分的錯誤碼', async (t) => {
|
|
const stub = await withStub(t);
|
|
|
|
const { json } = await run(
|
|
['--comment', '99999', '--kind', 'inline', '--body', '已修正。'],
|
|
stub,
|
|
);
|
|
|
|
assert.equal(json.error.code, 'COMMENT_NOT_FOUND');
|
|
assert.deepEqual(posted(stub), []);
|
|
});
|
|
|
|
// ── 一般留言:平的,回覆要引用得出在回誰 ───────────────────────────
|
|
|
|
test('回覆一般留言時新增一則留言,並引用原文讓人看得出在回誰', async (t) => {
|
|
const stub = await withStub(t);
|
|
|
|
const { code, json } = await run(
|
|
['--comment', String(GENERAL.id), '--kind', 'general', '--body', '已改名為 claimWorkPackage。'],
|
|
stub,
|
|
);
|
|
|
|
assert.equal(code, 0, JSON.stringify(json));
|
|
const comment = posted(stub).find((r) => r.path.endsWith(`/issues/${INDEX}/comments`));
|
|
assert.match(comment.body.body, /已改名為 claimWorkPackage。/);
|
|
assert.match(comment.body.body, />.*命名再想想/s, 'PR 的一般留言是平的,要引用才看得出在回誰');
|
|
});
|
|
|
|
test('一般留言用 +1 標記,不是 resolve', async (t) => {
|
|
const stub = await withStub(t);
|
|
|
|
const { json } = await run(
|
|
['--comment', String(GENERAL.id), '--kind', 'general', '--body', '已處理。'],
|
|
stub,
|
|
);
|
|
|
|
const reaction = posted(stub).find((r) => r.path.endsWith('/reactions'));
|
|
assert.deepEqual(reaction.body, { content: '+1' });
|
|
assert.equal(json.data.標記方式, 'reaction');
|
|
});
|
|
|
|
// ── review 總評:標不了,但仍要回 ─────────────────────────────────
|
|
|
|
test('回覆 review 總評並用它在 issue comment 表裡的 id 打 +1', async (t) => {
|
|
// 總評標得了,只是 reaction 要掛在另一個 id 上;用 review id 會 404
|
|
const stub = await withStub(t);
|
|
|
|
const { code, json } = await run(
|
|
['--comment', String(REVIEW.id), '--kind', 'review', '--body', '兩處都已修正。'],
|
|
stub,
|
|
);
|
|
|
|
assert.equal(code, 0, JSON.stringify(json));
|
|
assert.equal(json.data.已標記, true);
|
|
assert.equal(json.data.標記方式, 'reaction');
|
|
const reaction = posted(stub).find((r) => r.path.includes('/reactions'));
|
|
assert.ok(
|
|
reaction.path.endsWith(`/issues/comments/${REVIEW.commentId}/reactions`),
|
|
`reaction 要打在 ${REVIEW.commentId} 上,不是 review 的 ${REVIEW.id}`,
|
|
);
|
|
});
|
|
|
|
test('總評在 timeline 上對不到那一份時,如實說標不了而不是硬打', async (t) => {
|
|
const stub = await withStub(t, {
|
|
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/timeline`]: { status: 200, body: [] },
|
|
});
|
|
|
|
const { code, json } = await run(
|
|
['--comment', String(REVIEW.id), '--kind', 'review', '--body', '已修正。'],
|
|
stub,
|
|
);
|
|
|
|
assert.equal(code, 0, JSON.stringify(json));
|
|
assert.equal(json.data.已標記, false);
|
|
assert.equal(json.data.標記方式, null);
|
|
assert.match(json.data.note ?? '', /標記/);
|
|
assert.equal(posted(stub).some((r) => r.path.includes('/reactions')), false);
|
|
});
|
|
|
|
test('輸出的欄位形狀固定:用不到的欄位是 null,不是不存在', async (t) => {
|
|
const stub = await withStub(t);
|
|
|
|
const inline = await run(
|
|
['--comment', String(INLINE.id), '--kind', 'inline', '--body', '已修正。'], stub);
|
|
const stub2 = await withStub(t);
|
|
const general = await run(
|
|
['--comment', String(GENERAL.id), '--kind', 'general', '--body', '已修正。'], stub2);
|
|
|
|
for (const { json } of [inline, general]) {
|
|
for (const key of ['已標記', '標記方式', '位置', 'note']) {
|
|
assert.ok(key in json.data, `輸出少了 ${key}:下游不該為此多寫一種分支`);
|
|
}
|
|
}
|
|
assert.equal(general.json.data.位置, null, '一般留言沒有位置,但欄位要在');
|
|
});
|
|
|
|
// ── 輸入 ───────────────────────────────────────────────────────────
|
|
|
|
test('--kind 不是三類之一時擋下,並列出可用的', async (t) => {
|
|
const stub = await withStub(t);
|
|
|
|
const { json } = await run(
|
|
['--comment', String(GENERAL.id), '--kind', '行內', '--body', '已處理。'],
|
|
stub,
|
|
);
|
|
|
|
assert.equal(json.error.code, 'BAD_KIND');
|
|
assert.match(json.error.message, /inline/);
|
|
});
|
|
|
|
test('回覆內容是空的時候擋下', async (t) => {
|
|
const stub = await withStub(t);
|
|
|
|
const { json } = await run(
|
|
['--comment', String(GENERAL.id), '--kind', 'general', '--body', ' '],
|
|
stub,
|
|
);
|
|
|
|
assert.equal(json.error.code, 'EMPTY_REPLY');
|
|
assert.deepEqual(posted(stub), []);
|
|
});
|
|
|
|
// ── --dry-run ─────────────────────────────────────────────────────
|
|
|
|
test('--dry-run 印出將發出的回覆與標記,且不張貼', async (t) => {
|
|
const stub = await withStub(t);
|
|
|
|
const { code, json } = await run(
|
|
['--comment', String(INLINE.id), '--kind', 'inline', '--body', '已修正。', '--dry-run'],
|
|
stub,
|
|
);
|
|
|
|
assert.equal(code, 0);
|
|
assert.equal(json.data.dryRun, true);
|
|
assert.deepEqual(
|
|
json.data.requests.map((r) => `${r.method} ${r.path}`),
|
|
[
|
|
`POST /repos/${REPO}/pulls/${INDEX}/reviews`,
|
|
`POST /repos/${REPO}/pulls/comments/${INLINE.id}/resolve`,
|
|
],
|
|
);
|
|
assert.deepEqual(posted(stub), [], '預覽不得張貼');
|
|
});
|
|
|
|
test('--dry-run 照樣查得出位置:位置錯了不該等到實跑才發現', async (t) => {
|
|
const stub = await withStub(t);
|
|
|
|
const { json } = await run(
|
|
['--comment', String(INLINE.id), '--kind', 'inline', '--body', '已修正。', '--dry-run'],
|
|
stub,
|
|
);
|
|
|
|
assert.deepEqual(json.data.requests[0].body.comments, [
|
|
{ path: INLINE.path, new_position: INLINE.position, body: '已修正。' },
|
|
]);
|
|
});
|
|
|
|
test('--dry-run 對總評印出回覆與 reaction 兩步', async (t) => {
|
|
const stub = await withStub(t);
|
|
|
|
const { json } = await run(
|
|
['--comment', String(REVIEW.id), '--kind', 'review', '--body', '已修正。', '--dry-run'],
|
|
stub,
|
|
);
|
|
|
|
assert.deepEqual(
|
|
json.data.requests.map((r) => `${r.method} ${r.path}`),
|
|
[
|
|
`POST /repos/${REPO}/issues/${INDEX}/comments`,
|
|
`POST /repos/${REPO}/issues/comments/${REVIEW.commentId}/reactions`,
|
|
],
|
|
);
|
|
});
|
|
|
|
test('PENDING 的 review 回不了:reviewer 自己都還沒送出', async (t) => {
|
|
const stub = await withStub(t, {
|
|
[`GET /api/v1/repos/${REPO}/pulls/${INDEX}/reviews`]: {
|
|
status: 200,
|
|
body: [{ id: REVIEW.id, body: '草稿', state: 'PENDING', user: { login: 'r' } }],
|
|
},
|
|
});
|
|
|
|
const { json } = await run(
|
|
['--comment', String(REVIEW.id), '--kind', 'review', '--body', '已修正。'],
|
|
stub,
|
|
);
|
|
|
|
assert.equal(json.error.code, 'COMMENT_NOT_FOUND');
|
|
assert.deepEqual(posted(stub), []);
|
|
});
|