Files
tea-sdlc/scripts/pr-threads.js
T
jiantw83andClaude Opus 5 43764f457e refactor(pr-comments): 三類留言的讀取收進 pr-threads
pr-watch 要數「還有幾則沒處理」,數的是與 pr-comments 完全同一件事:三類留言分散在
三個端點,一般留言與總評看自己打的 +1、行內留言看有沒有被 resolve,而總評的 reaction
掛在它的 issue comment id 上。這套規則寫兩份,遲早會一邊認自己的 +1、另一邊認任何人的
——而那個差異要等到有留言被靜靜跳過才會被發現。

pr-comments 只留輸入輸出,行為不變。

議題 #41

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:59:30 +08:00

191 lines
7.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 讀 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<object[]>}
*/
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<object>}
*/
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);
}