#!/usr/bin/env node /** * 讀 PR 上的三類留言:一般留言、review 總評、行內留言。 * * 三類分散在三個端點,漏掉任何一類就會有 reviewer 的意見沒被處理——而那正是 * `/sdlc-fix` 存在的理由。這一支只讀不寫,分類(必改/建議)由讀到內容的人判斷。 * * 「已處理」在三類上的機制不同: * - 一般留言、review 總評 → 自己打的 `+1` reaction * - 行內留言 → 有沒有被 resolve(只有 review comment 有 resolve 端點) * * **總評的 reaction 掛在它的 issue comment id 上,不是 review id。** Gitea 的 review * 總評在 issue comment 表裡也有一份,兩個 id 不同命名空間——拿 review id 去打 * reaction 會 404。那一份的 id 由 timeline 給(`type: 'review'` 的項目帶 `review_id`)。 * * reaction 要是**自己**打的才算已處理:reviewer 對留言按讚是「我同意」,不是 * 「這則我處理過了」,把它當成已處理會讓那一則被靜靜跳過。 * * 用法: * node scripts/pr-comments.js --repo owner/name --index 45 [--host <網址>] [--dry-run] */ import { ScriptError, expectOk, giteaRequest, main, pages, parseFlags, parseIndex, parseRepo, preflight, resolveLogin, } from './lib.js'; /** 還沒送出的 review:reviewer 自己都還看不到,不該被當成意見 */ const DRAFT = 'PENDING'; main(async () => { const flags = parseFlags(process.argv.slice(2), { required: ['repo', 'index'], optional: ['host'], booleans: ['dry-run'], }); const repo = parseRepo(flags.repo); const index = parseIndex(flags.index); const pullPath = `/repos/${repo}/pulls/${index}`; if (flags['dry-run']) { return { dryRun: true, repo, index, requests: [ { method: 'GET', path: '/user' }, { method: 'GET', path: pullPath }, { method: 'GET', path: `/repos/${repo}/issues/${index}/comments` }, { method: 'GET', path: `${pullPath}/reviews` }, { method: 'GET', path: `/repos/${repo}/issues/${index}/timeline` }, ], note: '每則一般留言還會各查一次 reaction、每個 review 還會各查一次它的行內留言;' + '次數取決於留言數,事前無法列舉。', }; } const login = resolveLogin({ host: flags.host }); await preflight(login, repo); const me = expectOk(await giteaRequest(login, 'GET', '/user'), 'GET /user').login; const pull = await fetchPull(login, repo, index, pullPath); const 留言 = [ ...(await readGeneral(login, repo, index, me)), ...(await readReviews(login, repo, index, pullPath, me)), ]; return { repo, index: pull.number, title: pull.title, url: pull.html_url, state: pull.state, 留言, 未處理數: 留言.filter((comment) => !comment.已處理).length, }; }); async function fetchPull(login, repo, index, path) { const response = await giteaRequest(login, 'GET', path); if (response.status === 404) { throw new ScriptError('PULL_NOT_FOUND', `${repo} 沒有編號 ${index} 的 PR`); } if (response.status === 403) { throw new ScriptError('NO_READ_ACCESS', `目前的帳號沒有 ${repo} 的 PR ${index} 的讀取權`); } return expectOk(response, `GET ${path}`); } /** * 一般留言。PR 在 Gitea 裡也是 issue,所以走 issue 的留言端點。 * 內容是空的那些多半是狀態變更的系統紀錄(指派、改標題),不是意見。 */ async function readGeneral(login, repo, index, me) { const path = `/repos/${repo}/issues/${index}/comments`; const 留言 = []; for await (const comments of pages(login, path, { limitCode: 'COMMENT_LIMIT', limitHint: `${path} 的留言太多,讀不完整份清單`, })) { for (const comment of comments) { if ((comment.body ?? '').trim() === '') continue; 留言.push({ id: comment.id, 類型: '一般', 作者: comment.user?.login ?? '', 內容: comment.body, 已處理: await markedByMe(login, repo, comment.id, me), 可標記: true, }); } } return 留言; } /** * review 的總評與它底下的行內留言。 * * 總評的 id 要用它在 issue comment 表裡的那一份(timeline 給),reaction 才打得上去; * 行內留言則要用 review 自己的 id 去查。兩個 id 都要,所以兩邊都讀。 */ async function readReviews(login, repo, index, pullPath, me) { const path = `${pullPath}/reviews`; const commentIds = await reviewCommentIds(login, repo, index); const 留言 = []; for await (const reviews of pages(login, path, { limitCode: 'REVIEW_LIMIT', limitHint: `${path} 的 review 太多,讀不完整份清單`, })) { for (const review of reviews) { if (review.state === DRAFT) continue; const commentId = commentIds.get(review.id); if ((review.body ?? '').trim() !== '') { 留言.push({ id: commentId ?? review.id, review: review.id, 類型: '總評', 作者: review.user?.login ?? '', 內容: review.body, // 找不到它在 issue comment 表裡的那一份就標不了——那時如實說,不要假裝可以 已處理: commentId === undefined ? false : await markedByMe(login, repo, commentId, me), 可標記: commentId !== undefined, }); } const commentsPath = `${path}/${review.id}/comments`; for await (const comments of pages(login, commentsPath, { limitCode: 'REVIEW_COMMENT_LIMIT', limitHint: `${commentsPath} 的行內留言太多,讀不完整份清單`, })) { for (const comment of comments) 留言.push(inlineComment(comment, review.id)); } } } return 留言; } /** * 一則行內留言。 * * 位置分兩側:留在新檔那一側用 `position`,留在被刪掉的那一行用 `original_position`, * Gitea 只會填其中一個。只讀 position 的話,留在刪除行的留言會得到 undefined, * 回覆時位置就送錯欄位、落到別的地方去。 */ function inlineComment(comment, reviewId) { const onNew = (comment.position ?? 0) > 0; return { id: comment.id, review: reviewId, 類型: '行內', 作者: comment.user?.login ?? '', 內容: comment.body, 檔案: comment.path, 行: onNew ? comment.position : comment.original_position, 側: onNew ? '新' : '舊', // 帶上 diff 片段:沒有它,agent 只看得到「這裡少了錯誤處理」而不知道哪裡 diff: comment.diff_hunk ?? '', // 回覆要落在同一個 commit 上,否則 PR 之後又推了新 commit 時行號對不上 commit: comment.commit_id ?? comment.original_commit_id ?? null, 已處理: comment.resolver != null, 可標記: true, }; } /** * review id → 它在 issue comment 表裡的那一則 id。 * 總評的 reaction 掛在後者上,而 reviews 端點只給得出前者。 */ async function reviewCommentIds(login, repo, index) { const path = `/repos/${repo}/issues/${index}/timeline`; const ids = new Map(); for await (const entries of pages(login, path, { limitCode: 'TIMELINE_LIMIT', limitHint: `${path} 的項目太多,對不齊總評的 reaction`, })) { for (const entry of entries) { if (entry.type === 'review' && entry.review_id) ids.set(entry.review_id, entry.id); } } return ids; } /** * 這一則是不是「我」標記過已處理。 * * Gitea 的留言物件不含 reaction,只能逐則再查一次。認的是自己打的 `+1`: * reviewer 對留言按讚是「我同意」,當成已處理會讓那一則被靜靜跳過。 */ async function markedByMe(login, repo, id, me) { const path = `/repos/${repo}/issues/comments/${id}/reactions`; const reactions = expectOk(await giteaRequest(login, 'GET', path), `GET ${path}`) ?? []; return reactions.some((reaction) => reaction.content === '+1' && reaction.user?.login === me); }