#!/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, listIssueComments, 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) { for await (const comment of listIssueComments(login, repo, index)) { if (comment.id === commentId) return comment; } 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; }