/** * 回覆一則 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), []); });