test(pr-reply): 回覆一則 PR 留言並標記已處理

「回在原本那一串底下」對三類是三件不同的事。行內留言沒有「回覆某一則」的端點,
把新留言指向同一個檔案與同一行,Gitea 才會把它排在原留言底下——位置是串的識別。位置不由
呼叫端給而是拿 id 查出來:手抄行號是這一段最容易錯的地方。回覆還帶上原留言的 commit_id,
否則 PR 之後又推了新 commit 時,同一個行號指的是別的程式碼。

標記排在回覆之後,回覆沒成功就不標記:沒回就標記等於謊稱處理過。

輸出三類同形:用不到的欄位填 null 而不是讓它消失,下游不必為了少一欄多寫一種分支。
This commit is contained in:
2026-09-17 08:46:34 +00:00
parent 759b53adeb
commit 8d9bcd65e3
+350
View File
@@ -0,0 +1,350 @@
/**
* 回覆一則 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 }],
},
[`POST /api/v1/repos/${REPO}/issues/comments/${REVIEW.commentId}/reactions`]: { status: 201, body: {} },
[`GET /api/v1/repos/${REPO}/issues/${INDEX}/comments`]: {
status: 200,
body: [{ id: GENERAL.id, body: GENERAL.body, user: { login: 'r' } }],
},
[`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), []);
});