diff --git a/scripts/pr-reply.js b/scripts/pr-reply.js new file mode 100644 index 0000000..941906d --- /dev/null +++ b/scripts/pr-reply.js @@ -0,0 +1,274 @@ +#!/usr/bin/env node +/** + * 回覆一則 PR 留言,並標記它已經處理過。 + * + * 「回在 reviewer 原本那一串底下」對三類留言是三件不同的事: + * + * - **行內留言**:Gitea 沒有「回覆某一則行內留言」的端點。把新留言指向**同一個檔案 + * 與同一行**,它就會排在原留言底下——位置是串的識別,抓錯就變成另開一串。 + * 位置不由呼叫端給,而是拿留言 id 去查出來:手抄行號是這一段最容易錯的地方。 + * - **一般留言**:PR 的一般留言是平的,沒有串。回覆就是新增一則,並引用原文開頭, + * 讓人看得出在回誰。 + * - **review 總評**:也是平的,回法同一般留言。標記用 `+1`,但 reaction 要打在它 + * **在 issue comment 表裡那一份**的 id 上——那份由 timeline 給,拿 review 自己的 + * id 去打會 404。對不到那一份時如實說標不了,不硬打。 + * + * 標記排在回覆之後,而且回覆沒成功就不標記:沒回就標記等於謊稱處理過。 + * + * 用法: + * node scripts/pr-reply.js --repo owner/name --index 45 --comment <留言 id> + * --kind inline|general|review --body '<回覆>' + * [--host <網址>] [--dry-run] + */ +import { + ScriptError, + expectOk, + giteaRequest, + main, + pages, + parseFlags, + parseIndex, + parseRepo, + preflight, + resolveLogin, +} from './lib.js'; + +/** `--kind` 認得的三類,與 pr-comments 的「行內/一般/總評」一一對應 */ +const KINDS = ['inline', 'general', 'review']; + +/** 還沒送出的 review:reviewer 自己都還看不到,不該回進去 */ +const DRAFT = 'PENDING'; + +/** 引用原文時保留的字數。引用是線索,不是複製一份。 */ +const QUOTE_MAX = 60; + +main(async () => { + const flags = parseFlags(process.argv.slice(2), { + required: ['repo', 'index', 'comment', 'kind', 'body'], + optional: ['host'], + booleans: ['dry-run'], + }); + const repo = parseRepo(flags.repo); + const index = parseIndex(flags.index); + const commentId = parseIndex(flags.comment, '--comment'); + const kind = parseKind(flags.kind); + const body = parseBody(flags.body); + const dryRun = flags['dry-run'] === true; + + const login = resolveLogin({ host: flags.host }); + if (!dryRun) await preflight(login, repo); + + // 位置與原文都要先查出來——試跑也查,位置錯了不該等到實跑才發現 + const plan = await planReply(login, repo, index, commentId, kind, body); + + if (dryRun) { + return { dryRun: true, repo, index, comment: commentId, kind, ...plan.報告, requests: plan.requests }; + } + + for (const { method, path, body: payload } of plan.requests) { + expectOk(await giteaRequest(login, method, path, { body: payload }), `${method} ${path}`); + } + return { repo, index, comment: commentId, kind, ...plan.報告 }; +}); + + +/** + * 算出要發哪幾個請求。 + * 分成「算」與「做」兩段,試跑印出的就是實跑會發的東西,不另外維護一份描述。 + * + * 輸出的欄位形狀三類一致:用不到的欄位填 `null` 而不是讓它消失, + * 下游才不必為了少一欄多寫一種分支。 + */ +async function planReply(login, repo, index, commentId, kind, body) { + if (kind === 'inline') { + const target = await findInline(login, repo, index, commentId); + return { + requests: [ + { + method: 'POST', + path: `/repos/${repo}/pulls/${index}/reviews`, + body: { + // event 是 COMMENT:回覆一則意見不該順手把整個 PR 標成通過或要求變更 + event: 'COMMENT', + // 落在原留言的那個 commit 上:PR 之後又推了新 commit 的話, + // 同一個行號在新 commit 上指的是別的程式碼 + ...(target.commit_id ? { commit_id: target.commit_id } : {}), + comments: [{ path: target.path, ...positionOf(target), body }], + }, + }, + { method: 'POST', path: `/repos/${repo}/pulls/comments/${commentId}/resolve`, body: {} }, + ], + 報告: { + 已標記: true, + 標記方式: 'resolve', + 位置: `${target.path}:${positionOf(target).new_position ?? positionOf(target).old_position}`, + note: null, + }, + }; + } + + if (kind === 'general') { + const original = await findGeneral(login, repo, index, commentId); + return { + requests: [ + { + method: 'POST', + path: `/repos/${repo}/issues/${index}/comments`, + body: { body: `${quote(original.body)}\n\n${body}` }, + }, + { + method: 'POST', + path: `/repos/${repo}/issues/comments/${commentId}/reactions`, + body: { content: '+1' }, + }, + ], + 報告: { 已標記: true, 標記方式: 'reaction', 位置: null, note: null }, + }; + } + + // review 總評:回覆走一般留言那條路,reaction 打在它的 issue comment 那一份上 + const review = await findReview(login, repo, index, commentId); + const markId = await reviewCommentId(login, repo, index, commentId); + const requests = [ + { + method: 'POST', + path: `/repos/${repo}/issues/${index}/comments`, + body: { body: `${quote(review.body)}\n\n${body}` }, + }, + ]; + if (markId !== undefined) { + requests.push({ + method: 'POST', + path: `/repos/${repo}/issues/comments/${markId}/reactions`, + body: { content: '+1' }, + }); + } + + return { + requests, + 報告: { + 已標記: markId !== undefined, + 標記方式: markId === undefined ? null : 'reaction', + 位置: null, + note: + markId === undefined + ? '在 timeline 上對不到這則總評在 issue comment 表裡的那一份,標記不上去;' + + '回覆已經發出,記得在修正摘要裡交代它。' + : null, + }, + }; +} + +/** + * 回覆要送哪一個位置欄位。 + * Gitea 只填 `position` 與 `original_position` 其中一個:新檔那一側用前者, + * 被刪掉的那一行用後者。送錯欄位,回覆會落到別的地方去。 + */ +function positionOf(comment) { + return (comment.position ?? 0) > 0 + ? { new_position: comment.position } + : { old_position: comment.original_position }; +} + +/** 把原文引用起來,讓平的留言看得出在回誰。太長就截斷——引用是線索,不是複製一份。 */ +function quote(text) { + const first = (text ?? '').trim().split('\n')[0]; + const shown = first.length > QUOTE_MAX ? `${first.slice(0, QUOTE_MAX)}…` : first; + return `> ${shown}`; +} + +/** 三類共用的一句話:id 對不上多半是 --kind 給錯了 */ +function notFound(index, kind, id) { + return new ScriptError( + 'COMMENT_NOT_FOUND', + `PR ${index} 上找不到 id 為 ${id} 的${kind};` + + '請確認 --kind 與 --comment 對得上(三類留言的 id 各自獨立),必要時重跑 pr-comments', + ); +} + +/** + * 從 PR 的所有 review 裡找出那一則行內留言,取得它的位置與 commit。 + * Gitea 沒有「依 id 取 review comment」的端點,只能逐個 review 翻。 + */ +async function findInline(login, repo, index, commentId) { + for await (const review of eachReview(login, repo, index)) { + const path = `/repos/${repo}/pulls/${index}/reviews/${review.id}/comments`; + for await (const comments of pages(login, path, { + limitCode: 'REVIEW_COMMENT_LIMIT', + limitHint: `${path} 的行內留言太多,找不完`, + })) { + const hit = comments.find((comment) => comment.id === commentId); + if (hit) return hit; + } + } + throw notFound(index, '行內留言', commentId); +} + +async function findGeneral(login, repo, index, commentId) { + const path = `/repos/${repo}/issues/${index}/comments`; + + for await (const comments of pages(login, path, { + limitCode: 'COMMENT_LIMIT', + limitHint: `${path} 的留言太多,找不完`, + })) { + const hit = comments.find((comment) => comment.id === commentId); + if (hit) return hit; + } + throw notFound(index, '一般留言', commentId); +} + +async function findReview(login, repo, index, reviewId) { + for await (const review of eachReview(login, repo, index)) { + if (review.id === reviewId) return review; + } + throw notFound(index, 'review', reviewId); +} + +/** + * 逐頁走過已送出的 review。 + * PENDING 的跳過:那是 reviewer 寫到一半的草稿,他自己都還看不到,不該回進去。 + */ +async function* eachReview(login, repo, index) { + const path = `/repos/${repo}/pulls/${index}/reviews`; + + for await (const reviews of pages(login, path, { + limitCode: 'REVIEW_LIMIT', + limitHint: `${path} 的 review 太多,找不完`, + })) { + for (const review of reviews) { + if (review.state !== DRAFT) yield review; + } + } +} + +/** 總評在 issue comment 表裡那一份的 id;對不到就回 undefined,讓呼叫端如實說標不了 */ +async function reviewCommentId(login, repo, index, reviewId) { + const path = `/repos/${repo}/issues/${index}/timeline`; + + for await (const entries of pages(login, path, { + limitCode: 'TIMELINE_LIMIT', + limitHint: `${path} 的項目太多,對不齊總評的 reaction`, + })) { + const hit = entries.find((entry) => entry.type === 'review' && entry.review_id === reviewId); + if (hit) return hit.id; + } + return undefined; +} + +function parseKind(value) { + if (!KINDS.includes(value)) { + throw new ScriptError( + 'BAD_KIND', + `--kind 需為 ${KINDS.join('/')} 其中一個,收到的是 ${value}`, + ); + } + return value; +} + +function parseBody(value) { + const body = value.trim(); + if (body === '') { + throw new ScriptError('EMPTY_REPLY', '--body 不能是空的:回覆要說出這一則是怎麼處理的'); + } + return body; +}