From 43764f457e763e134b7b4a395c5329143b43947e Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 16:59:30 +0800 Subject: [PATCH] =?UTF-8?q?refactor(pr-comments):=20=E4=B8=89=E9=A1=9E?= =?UTF-8?q?=E7=95=99=E8=A8=80=E7=9A=84=E8=AE=80=E5=8F=96=E6=94=B6=E9=80=B2?= =?UTF-8?q?=20pr-threads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pr-watch 要數「還有幾則沒處理」,數的是與 pr-comments 完全同一件事:三類留言分散在 三個端點,一般留言與總評看自己打的 +1、行內留言看有沒有被 resolve,而總評的 reaction 掛在它的 issue comment id 上。這套規則寫兩份,遲早會一邊認自己的 +1、另一邊認任何人的 ——而那個差異要等到有留言被靜靜跳過才會被發現。 pr-comments 只留輸入輸出,行為不變。 議題 #41 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/pr-comments.js | 168 ++---------------------------------- scripts/pr-threads.js | 190 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 162 deletions(-) create mode 100644 scripts/pr-threads.js diff --git a/scripts/pr-comments.js b/scripts/pr-comments.js index cae6943..53994bd 100644 --- a/scripts/pr-comments.js +++ b/scripts/pr-comments.js @@ -5,35 +5,23 @@ * 三類分散在三個端點,漏掉任何一類就會有 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 對留言按讚是「我同意」,不是 - * 「這則我處理過了」,把它當成已處理會讓那一則被靜靜跳過。 + * 讀取本身與「已處理」的判定在 `pr-threads.js`——`pr-watch` 要數同一件事, + * 規則寫兩份遲早會各自演化。 * * 用法: * 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'; +import { fetchPull, readPullComments, unhandledCount } from './pr-threads.js'; main(async () => { const flags = parseFlags(process.argv.slice(2), { @@ -67,11 +55,8 @@ main(async () => { 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)), - ]; + const pull = await fetchPull(login, repo, index); + const 留言 = await readPullComments(login, repo, index, me); return { repo, @@ -80,148 +65,7 @@ main(async () => { url: pull.html_url, state: pull.state, 留言, - 未處理數: 留言.filter((comment) => !comment.已處理).length, + 未處理數: unhandledCount(留言), }; }); - -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); -} diff --git a/scripts/pr-threads.js b/scripts/pr-threads.js new file mode 100644 index 0000000..2d4adb2 --- /dev/null +++ b/scripts/pr-threads.js @@ -0,0 +1,190 @@ +/** + * 讀 PR 上的三類留言:一般留言、review 總評、行內留言。 + * + * 兩支腳本共用這一份:`pr-comments` 把整份交給 `/sdlc-fix` 逐則處理, + * `pr-watch` 只數還有幾則沒處理。判定「已處理」的規則只能有一份——兩邊各寫一次, + * 遲早會一邊認自己打的 `+1`、另一邊認任何人的,而那個差異要等到有留言被靜靜跳過 + * 才會被發現。 + * + * 「已處理」在三類上的機制不同: + * - 一般留言、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 對留言按讚是「我同意」,不是 + * 「這則我處理過了」,把它當成已處理會讓那一則被靜靜跳過。 + */ +import { ScriptError, expectOk, giteaRequest, pages } from './lib.js'; + +/** 還沒送出的 review:reviewer 自己都還看不到,不該被當成意見 */ +const DRAFT = 'PENDING'; + +/** + * 讀齊三類留言。 + * @param {{base: string, token: string}} login + * @param {string} repo owner/name + * @param {number} index PR 編號 + * @param {string} me 自己的帳號,用來認「這則是我標的」 + * @returns {Promise} + */ +export async function readPullComments(login, repo, index, me) { + const pullPath = `/repos/${repo}/pulls/${index}`; + return [ + ...(await readGeneral(login, repo, index, me)), + ...(await readReviews(login, repo, index, pullPath, me)), + ]; +} + +/** 還沒被處理的則數。`/sdlc-fix` 要做的量,也是 `pr-watch` 的建議動作的依據。 */ +export function unhandledCount(留言) { + return 留言.filter((comment) => !comment.已處理).length; +} + +/** + * 讀一顆 PR。「不存在」與「沒有讀取權」要分得開——前者是編號打錯,後者是權限沒開。 + * @returns {Promise} + */ +export async function fetchPull(login, repo, index) { + const path = `/repos/${repo}/pulls/${index}`; + 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); +}